From 92d7a083e3761cdbe5a21165f920debb976c5827 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Thu, 19 Mar 2026 15:20:08 +0800 Subject: [PATCH 1/9] refactor: sprite renderable and text renderable --- packages/core/src/2d/sprite/ISpriteLayout.ts | 30 + .../core/src/2d/sprite/SpriteDataBinding.ts | 110 +++ .../core/src/2d/sprite/SpriteRenderable.ts | 398 ++++++++++ packages/core/src/2d/sprite/SpriteRenderer.ts | 403 ++-------- .../core/src/2d/sprite/WorldSpriteLayout.ts | 120 +++ packages/core/src/2d/sprite/index.ts | 6 + packages/core/src/2d/text/TextRenderable.ts | 702 ++++++++++++++++++ packages/core/src/2d/text/TextRenderer.ts | 692 +---------------- packages/core/src/2d/text/WorldTextLayout.ts | 54 ++ packages/core/src/2d/text/index.ts | 3 + packages/core/src/RenderPipeline/index.ts | 3 + packages/ui/src/component/advanced/Image.ts | 316 ++------ packages/ui/src/component/advanced/Text.ts | 676 ++--------------- .../src/component/advanced/UISpriteLayout.ts | 56 ++ tests/src/core/SpriteRenderer.test.ts | 12 +- 15 files changed, 1674 insertions(+), 1907 deletions(-) create mode 100644 packages/core/src/2d/sprite/ISpriteLayout.ts create mode 100644 packages/core/src/2d/sprite/SpriteDataBinding.ts create mode 100644 packages/core/src/2d/sprite/SpriteRenderable.ts create mode 100644 packages/core/src/2d/sprite/WorldSpriteLayout.ts create mode 100644 packages/core/src/2d/text/TextRenderable.ts create mode 100644 packages/core/src/2d/text/WorldTextLayout.ts create mode 100644 packages/ui/src/component/advanced/UISpriteLayout.ts diff --git a/packages/core/src/2d/sprite/ISpriteLayout.ts b/packages/core/src/2d/sprite/ISpriteLayout.ts new file mode 100644 index 0000000000..f7dd474bbe --- /dev/null +++ b/packages/core/src/2d/sprite/ISpriteLayout.ts @@ -0,0 +1,30 @@ +import { Vector2 } from "@galacean/engine-math"; +import { SpriteModifyFlags } from "../enums/SpriteModifyFlags"; + +/** + * Provides layout input (width, height, pivot, flip, referenceResolutionPerUnit) + * for sprite rendering. Different hosts use different layouts: + * + * - World-space (SpriteRenderer, SpriteMask): customWidth/automaticWidth, sprite.pivot, flipX/flipY + * - UI-space (Image, UI Mask): UITransform.size, UITransform.pivot, no flip + */ +export interface ISpriteLayout { + readonly width: number; + readonly height: number; + readonly pivot: Vector2; + readonly flipX: boolean; + readonly flipY: boolean; + readonly referenceResolutionPerUnit: number | undefined; + + /** + * Called when sprite property changes. Returns additional dirty flags the host should set. + * Only called for types that the layout cares about (size, pivot). + */ + onSpriteSizeChanged(): number; + onSpritePivotChanged(): number; + + /** + * Called when the sprite instance is replaced. Layout should reset internal state (e.g. auto-size). + */ + onSpriteInstanceChanged(): void; +} diff --git a/packages/core/src/2d/sprite/SpriteDataBinding.ts b/packages/core/src/2d/sprite/SpriteDataBinding.ts new file mode 100644 index 0000000000..432ab3f5a8 --- /dev/null +++ b/packages/core/src/2d/sprite/SpriteDataBinding.ts @@ -0,0 +1,110 @@ +import { ShaderProperty } from "../../shader/ShaderProperty"; +import { ShaderData } from "../../shader/ShaderData"; +import { SpriteModifyFlags } from "../enums/SpriteModifyFlags"; +import { Sprite } from "./Sprite"; + +/** + * Minimal host interface required by SpriteDataBinding. + * Both Renderer and UIRenderer satisfy this. + */ +export interface ISpriteDataBindingOwner { + shaderData: ShaderData; + _addResourceReferCount(resource: any, count: number): void; +} + +/** + * Manages sprite reference lifecycle: ref counting, change listener registration, + * and texture shader property binding. + * + * Shared by SpriteRenderable (SpriteRenderer/Image) and future SpriteMaskRenderable (SpriteMask/UIMask). + * Does NOT own _subChunk or dirty flags — those stay on the host for ISpriteRenderer compatibility. + */ +export class SpriteDataBinding { + private _sprite: Sprite = null; + private _owner: ISpriteDataBindingOwner; + private _textureProperty: ShaderProperty; + private _onSpriteChanged: (type: SpriteModifyFlags | null) => void; + + /** + * The current sprite. + */ + get sprite(): Sprite | null { + return this._sprite; + } + + set sprite(value: Sprite | null) { + const lastSprite = this._sprite; + if (lastSprite !== value) { + if (lastSprite) { + this._owner._addResourceReferCount(lastSprite, -1); + lastSprite._updateFlagManager.removeListener(this._handleSpritePropertyChange); + } + if (value) { + this._owner._addResourceReferCount(value, 1); + value._updateFlagManager.addListener(this._handleSpritePropertyChange); + this._owner.shaderData.setTexture(this._textureProperty, value.texture); + } else { + this._owner.shaderData.setTexture(this._textureProperty, null); + } + this._sprite = value; + // Notify: sprite instance changed (null = full change, not a specific property) + this._onSpriteChanged(null); + } + } + + /** + * @param owner - The renderer that owns this core + * @param textureProperty - Shader property for texture binding + * @param onSpriteChanged - Callback for sprite changes. `null` type = sprite instance replaced; otherwise specific property changed. + * texture and destroy are handled internally and NOT forwarded. + */ + constructor( + owner: ISpriteDataBindingOwner, + textureProperty: ShaderProperty, + onSpriteChanged: (type: SpriteModifyFlags | null) => void + ) { + this._owner = owner; + this._textureProperty = textureProperty; + this._onSpriteChanged = onSpriteChanged; + this._handleSpritePropertyChange = this._handleSpritePropertyChange.bind(this); + } + + /** + * Clone sprite reference to target core. Triggers target's setter (ref counting + listener). + */ + cloneTo(target: SpriteDataBinding): void { + target.sprite = this._sprite; + } + + /** + * Release sprite reference and listeners. Call from host's _onDestroy. + */ + destroy(): void { + const sprite = this._sprite; + if (sprite) { + this._owner._addResourceReferCount(sprite, -1); + sprite._updateFlagManager.removeListener(this._handleSpritePropertyChange); + } + this._sprite = null; + this._owner = null; + this._onSpriteChanged = null; + } + + /** + * Listener for sprite property changes. Handles texture/destroy internally, + * forwards all other changes to the behavior layer via callback. + */ + private _handleSpritePropertyChange(type: SpriteModifyFlags): void { + switch (type) { + case SpriteModifyFlags.texture: + this._owner.shaderData.setTexture(this._textureProperty, this._sprite.texture); + break; + case SpriteModifyFlags.destroy: + this.sprite = null; + break; + default: + this._onSpriteChanged(type); + break; + } + } +} diff --git a/packages/core/src/2d/sprite/SpriteRenderable.ts b/packages/core/src/2d/sprite/SpriteRenderable.ts new file mode 100644 index 0000000000..42afd18662 --- /dev/null +++ b/packages/core/src/2d/sprite/SpriteRenderable.ts @@ -0,0 +1,398 @@ +import { BoundingBox, Color, MathUtil, Vector2 } from "@galacean/engine-math"; +import { BatchUtils } from "../../RenderPipeline/BatchUtils"; +import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManager"; +import { RenderContext } from "../../RenderPipeline/RenderContext"; +import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; +import { SubRenderElement } from "../../RenderPipeline/SubRenderElement"; +import { Renderer, RendererUpdateFlags } from "../../Renderer"; +import { assignmentClone, ignoreClone } from "../../clone/CloneManager"; +import { Material } from "../../material"; +import { ShaderProperty } from "../../shader/ShaderProperty"; +import { Texture2D } from "../../texture"; +import { ISpriteAssembler } from "../assembler/ISpriteAssembler"; +import { ISpriteRenderer } from "../assembler/ISpriteRenderer"; +import { SimpleSpriteAssembler } from "../assembler/SimpleSpriteAssembler"; +import { SlicedSpriteAssembler } from "../assembler/SlicedSpriteAssembler"; +import { TiledSpriteAssembler } from "../assembler/TiledSpriteAssembler"; +import { SpriteDrawMode } from "../enums/SpriteDrawMode"; +import { SpriteModifyFlags } from "../enums/SpriteModifyFlags"; +import { SpriteTileMode } from "../enums/SpriteTileMode"; +import { ISpriteLayout } from "./ISpriteLayout"; +import { Sprite } from "./Sprite"; +import { SpriteDataBinding } from "./SpriteDataBinding"; + +/** + * @remarks Extends `RendererUpdateFlags`. + */ +export enum SpriteRenderableFlags { + /** Color. */ + Color = 0x2, + /** UV. */ + UV = 0x4, + + /** WorldVolume and UV. */ + WorldVolumeAndUV = 0x5, + /** WorldVolume, UV and Color. */ + WorldVolumeUVAndColor = 0x7, + /** All. */ + All = 0x7 +} + +type RendererConstructor = abstract new (...args: any[]) => Renderer; + +/** + * Public contract of the SpriteRenderable mixin. + */ +export interface ISpriteRenderable { + sprite: Sprite | null; + drawMode: SpriteDrawMode; + tileMode: SpriteTileMode; + tiledAdaptiveThreshold: number; + _subChunk: SubPrimitiveChunk; + _dataBinding: SpriteDataBinding; + _layout: ISpriteLayout; + _getChunkManager(): PrimitiveChunkManager; + _getDefaultSpriteMaterial(): Material; + _getSpriteAlpha(): number; + _submitSpriteRenderElement( + context: RenderContext, + material: Material, + subChunk: SubPrimitiveChunk, + texture: Texture2D + ): void; + _createLayout(): ISpriteLayout; + _initSpriteRenderable(textureProperty: ShaderProperty): void; +} + +/** + * Wiring mixin that provides shared sprite rendering logic for both 2D SpriteRenderer and UI Image. + * + * Discipline: this mixin only handles wiring (forwarding, lifecycle hookup, abstract declarations). + * All host-specific behavior is accessed through abstract methods, composition objects, and hooks. + * The mixin NEVER touches host private fields directly. + */ +export function SpriteRenderable( + Base: T +): (abstract new (...args: any[]) => ISpriteRenderable) & T { + abstract class SpriteRenderableHost extends Base implements ISpriteRenderer { + /** @internal */ + @ignoreClone + _subChunk: SubPrimitiveChunk; + /** @internal */ + @ignoreClone + _dataBinding: SpriteDataBinding; + /** @internal */ + @ignoreClone + _layout: ISpriteLayout; + + @ignoreClone + private _drawMode: SpriteDrawMode; + @ignoreClone + private _assembler: ISpriteAssembler; + @assignmentClone + private _tileMode: SpriteTileMode = SpriteTileMode.Continuous; + @assignmentClone + private _tiledAdaptiveThreshold: number = 0.5; + + // ===== Abstract methods: host MUST implement ===== + + /** The color used by assemblers. */ + abstract get color(): Color; + + /** Which PrimitiveChunkManager to allocate vertex data from. */ + abstract _getChunkManager(): PrimitiveChunkManager; + + /** Default material when material is null or destroyed. */ + abstract _getDefaultSpriteMaterial(): Material; + + /** Push the final render element to the appropriate pipeline. */ + abstract _submitSpriteRenderElement( + context: RenderContext, + material: Material, + subChunk: SubPrimitiveChunk, + texture: Texture2D + ): void; + + /** Create the layout for this host type. */ + abstract _createLayout(): ISpriteLayout; + + // ===== Methods with defaults: host CAN override ===== + + /** Final alpha multiplier. Default: 1. UI hosts override to globalAlpha. */ + _getSpriteAlpha(): number { + return 1; + } + + // ===== Public API (forwarding) ===== + + /** + * The Sprite to render. + */ + get sprite(): Sprite | null { + return this._dataBinding.sprite; + } + + set sprite(value: Sprite | null) { + this._dataBinding.sprite = value; + } + + /** + * The draw mode of the sprite. + */ + get drawMode(): SpriteDrawMode { + return this._drawMode; + } + + set drawMode(value: SpriteDrawMode) { + if (this._drawMode !== value) { + this._drawMode = value; + switch (value) { + case SpriteDrawMode.Simple: + this._assembler = SimpleSpriteAssembler; + break; + case SpriteDrawMode.Sliced: + this._assembler = SlicedSpriteAssembler; + break; + case SpriteDrawMode.Tiled: + this._assembler = TiledSpriteAssembler; + break; + default: + break; + } + this._assembler.resetData(this); + this._dirtyUpdateFlag |= SpriteRenderableFlags.WorldVolumeUVAndColor; + } + } + + /** + * The tiling mode of the sprite. (Only works in tiled mode.) + */ + get tileMode(): SpriteTileMode { + return this._tileMode; + } + + set tileMode(value: SpriteTileMode) { + if (this._tileMode !== value) { + this._tileMode = value; + if (this._drawMode === SpriteDrawMode.Tiled) { + this._dirtyUpdateFlag |= SpriteRenderableFlags.WorldVolumeUVAndColor; + } + } + } + + /** + * Stretch Threshold in Tile Adaptive Mode, specified in normalized. (Only works in tiled adaptive mode.) + */ + get tiledAdaptiveThreshold(): number { + return this._tiledAdaptiveThreshold; + } + + set tiledAdaptiveThreshold(value: number) { + if (value !== this._tiledAdaptiveThreshold) { + value = MathUtil.clamp(value, 0, 1); + this._tiledAdaptiveThreshold = value; + if (this._drawMode === SpriteDrawMode.Tiled) { + this._dirtyUpdateFlag |= SpriteRenderableFlags.WorldVolumeUVAndColor; + } + } + } + + // ===== Wiring: init ===== + + /** + * Initialize sprite renderable state. Must be called from subclass constructor. + * @param textureProperty - The shader property used for sprite texture binding. + * @internal + */ + _initSpriteRenderable(textureProperty: ShaderProperty): void { + this._dataBinding = new SpriteDataBinding( + this as any, + textureProperty, + this._onSpriteChanged.bind(this) + ); + this._layout = this._createLayout(); + this.drawMode = SpriteDrawMode.Simple; + this._dirtyUpdateFlag |= SpriteRenderableFlags.Color; + this.setMaterial(this._getDefaultSpriteMaterial()); + } + + // ===== Wiring: lifecycle ===== + + /** + * @internal + */ + override _updateTransformShaderData(context: RenderContext, onlyMVP: boolean, batched: boolean): void { + //@todo: Always update world positions to buffer, should opt + super._updateTransformShaderData(context, onlyMVP, true); + } + + /** + * @internal + */ + // @ts-ignore + override _cloneTo(target: SpriteRenderableHost): void { + // @ts-ignore + super._cloneTo(target); + this._dataBinding.cloneTo(target._dataBinding); + target.drawMode = this._drawMode; + } + + /** + * @internal + */ + override _canBatch(elementA: SubRenderElement, elementB: SubRenderElement): boolean { + return BatchUtils.canBatchSprite(elementA, elementB); + } + + /** + * @internal + */ + override _batch(elementA: SubRenderElement, elementB?: SubRenderElement): void { + BatchUtils.batchFor2D(elementA, elementB); + } + + protected override _updateBounds(worldBounds: BoundingBox): void { + const layout = this._layout; + if (this._dataBinding.sprite) { + this._assembler.updatePositions( + this, + this._transformEntity.transform.worldMatrix, + layout.width, + layout.height, + layout.pivot, + layout.flipX, + layout.flipY, + layout.referenceResolutionPerUnit + ); + } else { + const { worldPosition } = this._transformEntity.transform; + worldBounds.min.copyFrom(worldPosition); + worldBounds.max.copyFrom(worldPosition); + } + } + + protected override _render(context: RenderContext): void { + const sprite = this._dataBinding.sprite; + const layout = this._layout; + const width = layout.width; + const height = layout.height; + if (!sprite?.texture || !width || !height) { + return; + } + + let material = this.getMaterial(); + if (!material) { + return; + } + // @todo: This question needs to be raised rather than hidden. + if (material.destroyed) { + material = this._getDefaultSpriteMaterial(); + } + + const alpha = this._getSpriteAlpha(); + if (this.color.a * alpha <= 0) { + return; + } + + // Update position + if (this._dirtyUpdateFlag & RendererUpdateFlags.WorldVolume) { + this._assembler.updatePositions( + this, + this._transformEntity.transform.worldMatrix, + width, + height, + layout.pivot, + layout.flipX, + layout.flipY, + layout.referenceResolutionPerUnit + ); + this._dirtyUpdateFlag &= ~RendererUpdateFlags.WorldVolume; + } + + // Update uv + if (this._dirtyUpdateFlag & SpriteRenderableFlags.UV) { + this._assembler.updateUVs(this); + this._dirtyUpdateFlag &= ~SpriteRenderableFlags.UV; + } + + // Update color + if (this._dirtyUpdateFlag & SpriteRenderableFlags.Color) { + this._assembler.updateColor(this, alpha); + this._dirtyUpdateFlag &= ~SpriteRenderableFlags.Color; + } + + // Submit + this._submitSpriteRenderElement(context, material, this._subChunk, sprite.texture); + } + + protected override _onDestroy(): void { + this._dataBinding.destroy(); + + this._assembler = null; + this._layout = null; + if (this._subChunk) { + this._getChunkManager().freeSubChunk(this._subChunk); + this._subChunk = null; + } + + super._onDestroy(); + } + + // ===== Wiring: sprite change dispatch ===== + + /** + * Callback from SpriteDataBinding. + * `type === null` means sprite instance was replaced; otherwise a specific property changed. + */ + private _onSpriteChanged(type: SpriteModifyFlags | null): void { + if (type === null) { + // Sprite instance replaced — mark everything dirty, notify layout + this._dirtyUpdateFlag |= SpriteRenderableFlags.All; + this._layout.onSpriteInstanceChanged(); + return; + } + + switch (type) { + case SpriteModifyFlags.size: + this._dirtyUpdateFlag |= this._layout.onSpriteSizeChanged(); + switch (this._drawMode) { + case SpriteDrawMode.Sliced: + this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; + break; + case SpriteDrawMode.Tiled: + this._dirtyUpdateFlag |= SpriteRenderableFlags.WorldVolumeUVAndColor; + break; + default: + break; + } + break; + case SpriteModifyFlags.border: + switch (this._drawMode) { + case SpriteDrawMode.Sliced: + this._dirtyUpdateFlag |= SpriteRenderableFlags.WorldVolumeAndUV; + break; + case SpriteDrawMode.Tiled: + this._dirtyUpdateFlag |= SpriteRenderableFlags.WorldVolumeUVAndColor; + break; + default: + break; + } + break; + case SpriteModifyFlags.region: + case SpriteModifyFlags.atlasRegionOffset: + this._dirtyUpdateFlag |= SpriteRenderableFlags.WorldVolumeAndUV; + break; + case SpriteModifyFlags.atlasRegion: + this._dirtyUpdateFlag |= SpriteRenderableFlags.UV; + break; + case SpriteModifyFlags.pivot: + this._dirtyUpdateFlag |= this._layout.onSpritePivotChanged(); + break; + default: + break; + } + } + } + + return SpriteRenderableHost as unknown as (abstract new (...args: any[]) => ISpriteRenderable) & T; +} diff --git a/packages/core/src/2d/sprite/SpriteRenderer.ts b/packages/core/src/2d/sprite/SpriteRenderer.ts index c1b183ee7a..7f2461a39b 100644 --- a/packages/core/src/2d/sprite/SpriteRenderer.ts +++ b/packages/core/src/2d/sprite/SpriteRenderer.ts @@ -1,149 +1,29 @@ -import { BoundingBox, Color, MathUtil } from "@galacean/engine-math"; +import { Color } from "@galacean/engine-math"; import { Entity } from "../../Entity"; -import { BatchUtils } from "../../RenderPipeline/BatchUtils"; import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManager"; import { RenderContext } from "../../RenderPipeline/RenderContext"; import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; -import { SubRenderElement } from "../../RenderPipeline/SubRenderElement"; import { Renderer, RendererUpdateFlags } from "../../Renderer"; -import { assignmentClone, deepClone, ignoreClone } from "../../clone/CloneManager"; +import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { SpriteMaskLayer } from "../../enums/SpriteMaskLayer"; +import { Material } from "../../material"; import { ShaderProperty } from "../../shader/ShaderProperty"; -import { ISpriteAssembler } from "../assembler/ISpriteAssembler"; -import { ISpriteRenderer } from "../assembler/ISpriteRenderer"; -import { SimpleSpriteAssembler } from "../assembler/SimpleSpriteAssembler"; -import { SlicedSpriteAssembler } from "../assembler/SlicedSpriteAssembler"; -import { TiledSpriteAssembler } from "../assembler/TiledSpriteAssembler"; +import { Texture2D } from "../../texture"; +import { ISpriteLayout } from "./ISpriteLayout"; import { SpriteDrawMode } from "../enums/SpriteDrawMode"; import { SpriteMaskInteraction } from "../enums/SpriteMaskInteraction"; -import { SpriteModifyFlags } from "../enums/SpriteModifyFlags"; -import { SpriteTileMode } from "../enums/SpriteTileMode"; -import { Sprite } from "./Sprite"; -import { SpriteMaskLayer } from "../../enums/SpriteMaskLayer"; +import { SpriteRenderable, SpriteRenderableFlags } from "./SpriteRenderable"; +import { WorldSpriteLayout } from "./WorldSpriteLayout"; /** * Renders a Sprite for 2D graphics. */ -export class SpriteRenderer extends Renderer implements ISpriteRenderer { +export class SpriteRenderer extends SpriteRenderable(Renderer) { /** @internal */ static _textureProperty: ShaderProperty = ShaderProperty.getByName("renderer_SpriteTexture"); - /** @internal */ - @ignoreClone - _subChunk: SubPrimitiveChunk; - - @ignoreClone - private _drawMode: SpriteDrawMode; - @ignoreClone - private _assembler: ISpriteAssembler; - @assignmentClone - private _tileMode: SpriteTileMode = SpriteTileMode.Continuous; - @assignmentClone - private _tiledAdaptiveThreshold: number = 0.5; - @deepClone private _color: Color = new Color(1, 1, 1, 1); - @ignoreClone - private _sprite: Sprite = null; - - @ignoreClone - private _automaticWidth: number = 0; - @ignoreClone - private _automaticHeight: number = 0; - @assignmentClone - private _customWidth: number = undefined; - @assignmentClone - private _customHeight: number = undefined; - @assignmentClone - private _flipX: boolean = false; - @assignmentClone - private _flipY: boolean = false; - - /** - * The draw mode of the sprite renderer. - */ - get drawMode(): SpriteDrawMode { - return this._drawMode; - } - - set drawMode(value: SpriteDrawMode) { - if (this._drawMode !== value) { - this._drawMode = value; - switch (value) { - case SpriteDrawMode.Simple: - this._assembler = SimpleSpriteAssembler; - break; - case SpriteDrawMode.Sliced: - this._assembler = SlicedSpriteAssembler; - break; - case SpriteDrawMode.Tiled: - this._assembler = TiledSpriteAssembler; - break; - default: - break; - } - this._assembler.resetData(this); - this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.WorldVolumeUVAndColor; - } - } - - /** - * The tiling mode of the sprite renderer. (Only works in tiled mode.) - */ - get tileMode(): SpriteTileMode { - return this._tileMode; - } - - set tileMode(value: SpriteTileMode) { - if (this._tileMode !== value) { - this._tileMode = value; - if (this.drawMode === SpriteDrawMode.Tiled) { - this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.WorldVolumeUVAndColor; - } - } - } - - /** - * Stretch Threshold in Tile Adaptive Mode, specified in normalized. (Only works in tiled adaptive mode.) - */ - get tiledAdaptiveThreshold(): number { - return this._tiledAdaptiveThreshold; - } - - set tiledAdaptiveThreshold(value: number) { - if (value !== this._tiledAdaptiveThreshold) { - value = MathUtil.clamp(value, 0, 1); - this._tiledAdaptiveThreshold = value; - if (this.drawMode === SpriteDrawMode.Tiled) { - this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.WorldVolumeUVAndColor; - } - } - } - - /** - * The Sprite to render. - */ - get sprite(): Sprite { - return this._sprite; - } - - set sprite(value: Sprite | null) { - const lastSprite = this._sprite; - if (lastSprite !== value) { - if (lastSprite) { - this._addResourceReferCount(lastSprite, -1); - lastSprite._updateFlagManager.removeListener(this._onSpriteChange); - } - this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.All; - if (value) { - this._addResourceReferCount(value, 1); - value._updateFlagManager.addListener(this._onSpriteChange); - this.shaderData.setTexture(SpriteRenderer._textureProperty, value.texture); - } else { - this.shaderData.setTexture(SpriteRenderer._textureProperty, null); - } - this._sprite = value; - } - } /** * Rendering color for the Sprite graphic. @@ -166,20 +46,16 @@ export class SpriteRenderer extends Renderer implements ISpriteRenderer { * otherwise return `SpriteRenderer.sprite.width`. */ get width(): number { - if (this._customWidth !== undefined) { - return this._customWidth; - } else { - this._dirtyUpdateFlag & SpriteRendererUpdateFlags.AutomaticSize && this._calDefaultSize(); - return this._automaticWidth; - } + return (this._layout).width; } set width(value: number) { - if (this._customWidth !== value) { - this._customWidth = value; + const layout = this._layout; + if (layout.customWidth !== value) { + layout.width = value; this._dirtyUpdateFlag |= - this._drawMode === SpriteDrawMode.Tiled - ? SpriteRendererUpdateFlags.WorldVolumeUVAndColor + this.drawMode === SpriteDrawMode.Tiled + ? SpriteRenderableFlags.WorldVolumeUVAndColor : RendererUpdateFlags.WorldVolume; } } @@ -192,20 +68,16 @@ export class SpriteRenderer extends Renderer implements ISpriteRenderer { * otherwise return `SpriteRenderer.sprite.height`. */ get height(): number { - if (this._customHeight !== undefined) { - return this._customHeight; - } else { - this._dirtyUpdateFlag & SpriteRendererUpdateFlags.AutomaticSize && this._calDefaultSize(); - return this._automaticHeight; - } + return (this._layout).height; } set height(value: number) { - if (this._customHeight !== value) { - this._customHeight = value; + const layout = this._layout; + if (layout.customHeight !== value) { + layout.height = value; this._dirtyUpdateFlag |= - this._drawMode === SpriteDrawMode.Tiled - ? SpriteRendererUpdateFlags.WorldVolumeUVAndColor + this.drawMode === SpriteDrawMode.Tiled + ? SpriteRenderableFlags.WorldVolumeUVAndColor : RendererUpdateFlags.WorldVolume; } } @@ -214,12 +86,13 @@ export class SpriteRenderer extends Renderer implements ISpriteRenderer { * Flips the sprite on the X axis. */ get flipX(): boolean { - return this._flipX; + return (this._layout).flipX; } set flipX(value: boolean) { - if (this._flipX !== value) { - this._flipX = value; + const layout = this._layout; + if (layout.flipX !== value) { + layout.flipX = value; this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; } } @@ -228,12 +101,13 @@ export class SpriteRenderer extends Renderer implements ISpriteRenderer { * Flips the sprite on the Y axis. */ get flipY(): boolean { - return this._flipY; + return (this._layout).flipY; } set flipY(value: boolean) { - if (this._flipY !== value) { - this._flipY = value; + const layout = this._layout; + if (layout.flipY !== value) { + layout.flipY = value; this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; } } @@ -267,228 +141,49 @@ export class SpriteRenderer extends Renderer implements ISpriteRenderer { */ constructor(entity: Entity) { super(entity); - this.drawMode = SpriteDrawMode.Simple; - this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.Color; - this.setMaterial(this._engine._basicResources.spriteDefaultMaterial); - this._onSpriteChange = this._onSpriteChange.bind(this); + this._initSpriteRenderable(SpriteRenderer._textureProperty); //@ts-ignore this._color._onValueChanged = this._onColorChanged.bind(this); } - /** - * @internal - */ - override _updateTransformShaderData(context: RenderContext, onlyMVP: boolean, batched: boolean): void { - //@todo: Always update world positions to buffer, should opt - super._updateTransformShaderData(context, onlyMVP, true); - } + // ===== Abstract implementations ===== - /** - * @internal - */ - override _cloneTo(target: SpriteRenderer): void { - super._cloneTo(target); - target.sprite = this._sprite; - target.drawMode = this._drawMode; - } - - /** - * @internal - */ - override _canBatch(elementA: SubRenderElement, elementB: SubRenderElement): boolean { - return BatchUtils.canBatchSprite(elementA, elementB); - } - - /** - * @internal - */ - override _batch(elementA: SubRenderElement, elementB?: SubRenderElement): void { - BatchUtils.batchFor2D(elementA, elementB); - } - - /** - * @internal - */ - _getChunkManager(): PrimitiveChunkManager { + /** @internal */ + override _getChunkManager(): PrimitiveChunkManager { return this.engine._batcherManager.primitiveChunkManager2D; } - protected override _updateBounds(worldBounds: BoundingBox): void { - const sprite = this._sprite; - if (sprite) { - this._assembler.updatePositions( - this, - this._transformEntity.transform.worldMatrix, - this.width, - this.height, - sprite.pivot, - this._flipX, - this._flipY - ); - } else { - const { worldPosition } = this._transformEntity.transform; - worldBounds.min.copyFrom(worldPosition); - worldBounds.max.copyFrom(worldPosition); - } + /** @internal */ + override _getDefaultSpriteMaterial(): Material { + return this._engine._basicResources.spriteDefaultMaterial; } - protected override _render(context: RenderContext): void { - const { _sprite: sprite } = this; - if (!sprite?.texture || !this.width || !this.height) { - return; - } - - let material = this.getMaterial(); - if (!material) { - return; - } - // @todo: This question needs to be raised rather than hidden. - if (material.destroyed) { - material = this._engine._basicResources.spriteDefaultMaterial; - } - - // Update position - if (this._dirtyUpdateFlag & RendererUpdateFlags.WorldVolume) { - this._assembler.updatePositions( - this, - this._transformEntity.transform.worldMatrix, - this.width, - this.height, - sprite.pivot, - this._flipX, - this._flipY - ); - this._dirtyUpdateFlag &= ~RendererUpdateFlags.WorldVolume; - } - - // Update uv - if (this._dirtyUpdateFlag & SpriteRendererUpdateFlags.UV) { - this._assembler.updateUVs(this); - this._dirtyUpdateFlag &= ~SpriteRendererUpdateFlags.UV; - } - - // Update color - if (this._dirtyUpdateFlag & SpriteRendererUpdateFlags.Color) { - this._assembler.updateColor(this, 1); - this._dirtyUpdateFlag &= ~SpriteRendererUpdateFlags.Color; - } - - // Push primitive + /** @internal */ + override _submitSpriteRenderElement( + context: RenderContext, + material: Material, + subChunk: SubPrimitiveChunk, + texture: Texture2D + ): void { const camera = context.camera; const engine = camera.engine; const renderElement = engine._renderElementPool.get(); renderElement.set(this.priority, this._distanceForSort); const subRenderElement = engine._subRenderElementPool.get(); - const subChunk = this._subChunk; - subRenderElement.set(this, material, subChunk.chunk.primitive, subChunk.subMesh, this.sprite.texture, subChunk); + subRenderElement.set(this, material, subChunk.chunk.primitive, subChunk.subMesh, texture, subChunk); renderElement.addSubRenderElement(subRenderElement); camera._renderPipeline.pushRenderElement(context, renderElement); } - protected override _onDestroy(): void { - const sprite = this._sprite; - if (sprite) { - this._addResourceReferCount(sprite, -1); - sprite._updateFlagManager.removeListener(this._onSpriteChange); - } - - super._onDestroy(); - - this._sprite = null; - this._assembler = null; - if (this._subChunk) { - this._getChunkManager().freeSubChunk(this._subChunk); - this._subChunk = null; - } - } - - private _calDefaultSize(): void { - const sprite = this._sprite; - if (sprite) { - this._automaticWidth = sprite.width; - this._automaticHeight = sprite.height; - } else { - this._automaticWidth = this._automaticHeight = 0; - } - this._dirtyUpdateFlag &= ~SpriteRendererUpdateFlags.AutomaticSize; + /** @internal */ + override _createLayout(): ISpriteLayout { + return new WorldSpriteLayout(() => this.sprite); } - @ignoreClone - private _onSpriteChange(type: SpriteModifyFlags): void { - switch (type) { - case SpriteModifyFlags.texture: - this.shaderData.setTexture(SpriteRenderer._textureProperty, this.sprite.texture); - break; - case SpriteModifyFlags.size: - this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.AutomaticSize; - if (this._customWidth === undefined || this._customHeight === undefined) { - this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; - } - switch (this._drawMode) { - case SpriteDrawMode.Simple: - // When the width and height of `SpriteRenderer` are `undefined`, - // the `size` of `Sprite` will affect the position of `SpriteRenderer`. - if (this._customWidth === undefined || this._customHeight === undefined) { - this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; - } - break; - case SpriteDrawMode.Sliced: - this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; - break; - case SpriteDrawMode.Tiled: - this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.WorldVolumeUVAndColor; - break; - } - break; - case SpriteModifyFlags.border: - switch (this._drawMode) { - case SpriteDrawMode.Sliced: - this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.WorldVolumeAndUV; - break; - case SpriteDrawMode.Tiled: - this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.WorldVolumeUVAndColor; - break; - default: - break; - } - break; - case SpriteModifyFlags.region: - case SpriteModifyFlags.atlasRegionOffset: - this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.WorldVolumeAndUV; - break; - case SpriteModifyFlags.atlasRegion: - this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.UV; - break; - case SpriteModifyFlags.pivot: - this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; - break; - case SpriteModifyFlags.destroy: - this.sprite = null; - break; - } - } + // ===== Private ===== @ignoreClone private _onColorChanged(): void { - this._dirtyUpdateFlag |= SpriteRendererUpdateFlags.Color; + this._dirtyUpdateFlag |= SpriteRenderableFlags.Color; } } - -/** - * @remarks Extends `RendererUpdateFlags`. - */ -enum SpriteRendererUpdateFlags { - /** UV. */ - UV = 0x2, - /** Color. */ - Color = 0x4, - /** Automatic Size. */ - AutomaticSize = 0x8, - - /** WorldVolume and UV. */ - WorldVolumeAndUV = 0x3, - /** WorldVolume, UV and Color. */ - WorldVolumeUVAndColor = 0x7, - /** All. */ - All = 0xf -} diff --git a/packages/core/src/2d/sprite/WorldSpriteLayout.ts b/packages/core/src/2d/sprite/WorldSpriteLayout.ts new file mode 100644 index 0000000000..027cb9dd18 --- /dev/null +++ b/packages/core/src/2d/sprite/WorldSpriteLayout.ts @@ -0,0 +1,120 @@ +import { Vector2 } from "@galacean/engine-math"; +import { RendererUpdateFlags } from "../../Renderer"; +import { Sprite } from "./Sprite"; +import { ISpriteLayout } from "./ISpriteLayout"; + +/** + * Layout for world-space sprite renderers (SpriteRenderer, SpriteMask). + * Provides custom/automatic width-height, flipX/flipY, and sprite.pivot. + */ +export class WorldSpriteLayout implements ISpriteLayout { + private _customWidth: number = undefined; + private _customHeight: number = undefined; + private _automaticWidth: number = 0; + private _automaticHeight: number = 0; + private _autoSizeDirty: boolean = true; + private _flipX: boolean = false; + private _flipY: boolean = false; + private _spriteGetter: () => Sprite | null; + + constructor(spriteGetter: () => Sprite | null) { + this._spriteGetter = spriteGetter; + } + + // --- Width / Height --- + + get width(): number { + if (this._customWidth !== undefined) { + return this._customWidth; + } else { + if (this._autoSizeDirty) { + this._calDefaultSize(); + } + return this._automaticWidth; + } + } + + set width(value: number) { + this._customWidth = value; + } + + get height(): number { + if (this._customHeight !== undefined) { + return this._customHeight; + } else { + if (this._autoSizeDirty) { + this._calDefaultSize(); + } + return this._automaticHeight; + } + } + + set height(value: number) { + this._customHeight = value; + } + + get customWidth(): number { + return this._customWidth; + } + + get customHeight(): number { + return this._customHeight; + } + + // --- Flip --- + + get flipX(): boolean { + return this._flipX; + } + + set flipX(value: boolean) { + this._flipX = value; + } + + get flipY(): boolean { + return this._flipY; + } + + set flipY(value: boolean) { + this._flipY = value; + } + + // --- ISpriteLayout --- + + get pivot(): Vector2 { + return this._spriteGetter()?.pivot; + } + + get referenceResolutionPerUnit(): number | undefined { + return undefined; + } + + onSpriteInstanceChanged(): void { + this._autoSizeDirty = true; + } + + onSpriteSizeChanged(): number { + this._autoSizeDirty = true; + if (this._customWidth === undefined || this._customHeight === undefined) { + return RendererUpdateFlags.WorldVolume; + } + return 0; + } + + onSpritePivotChanged(): number { + return RendererUpdateFlags.WorldVolume; + } + + // --- Private --- + + private _calDefaultSize(): void { + const sprite = this._spriteGetter(); + if (sprite) { + this._automaticWidth = sprite.width; + this._automaticHeight = sprite.height; + } else { + this._automaticWidth = this._automaticHeight = 0; + } + this._autoSizeDirty = false; + } +} diff --git a/packages/core/src/2d/sprite/index.ts b/packages/core/src/2d/sprite/index.ts index 162d016472..a1f5c54ab3 100644 --- a/packages/core/src/2d/sprite/index.ts +++ b/packages/core/src/2d/sprite/index.ts @@ -1,3 +1,9 @@ +export type { ISpriteLayout } from "./ISpriteLayout"; export { Sprite } from "./Sprite"; +export { SpriteDataBinding } from "./SpriteDataBinding"; +export type { ISpriteDataBindingOwner } from "./SpriteDataBinding"; +export type { ISpriteRenderable } from "./SpriteRenderable"; +export { SpriteRenderable, SpriteRenderableFlags } from "./SpriteRenderable"; export { SpriteMask } from "./SpriteMask"; export { SpriteRenderer } from "./SpriteRenderer"; +export { WorldSpriteLayout } from "./WorldSpriteLayout"; diff --git a/packages/core/src/2d/text/TextRenderable.ts b/packages/core/src/2d/text/TextRenderable.ts new file mode 100644 index 0000000000..3187bbd117 --- /dev/null +++ b/packages/core/src/2d/text/TextRenderable.ts @@ -0,0 +1,702 @@ +import { BoundingBox, Color, Vector3 } from "@galacean/engine-math"; +import { Engine } from "../../Engine"; +import { BatchUtils } from "../../RenderPipeline/BatchUtils"; +import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManager"; +import { RenderContext } from "../../RenderPipeline/RenderContext"; +import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; +import { SubRenderElement } from "../../RenderPipeline/SubRenderElement"; +import { Renderer, RendererUpdateFlags } from "../../Renderer"; +import { assignmentClone, ignoreClone } from "../../clone/CloneManager"; +import { Material } from "../../material"; +import { ShaderData, ShaderProperty } from "../../shader"; +import { ShaderDataGroup } from "../../shader/enums/ShaderDataGroup"; +import { Texture2D } from "../../texture"; +import { FontStyle } from "../enums/FontStyle"; +import { TextHorizontalAlignment, TextVerticalAlignment } from "../enums/TextAlignment"; +import { OverflowMode } from "../enums/TextOverflow"; +import { ISpriteLayout } from "../sprite/ISpriteLayout"; +import { CharRenderInfo } from "./CharRenderInfo"; +import { Font } from "./Font"; +import { ITextRenderer } from "./ITextRenderer"; +import { SubFont } from "./SubFont"; +import { TextUtils } from "./TextUtils"; + +/** + * @remarks Extends `RendererUpdateFlags`. + */ +export enum TextRenderableFlags { + /** Color. */ + Color = 0x2, + /** SubFont needs reset. */ + SubFont = 0x4, + /** Local positions and bounds need recalculation. */ + LocalPositionBounds = 0x8, + /** World positions need update. */ + WorldPosition = 0x10, + + /** Position = WorldVolume | LocalPositionBounds | WorldPosition. */ + Position = 0x19, + /** Font = SubFont | Position. */ + Font = 0x1d, + /** All. */ + All = 0x1f +} + +type RendererConstructor = abstract new (...args: any[]) => Renderer; + +/** + * Public contract of the TextRenderable mixin. + */ +export interface ITextRenderable { + text: string; + font: Font; + fontSize: number; + fontStyle: FontStyle; + lineSpacing: number; + characterSpacing: number; + horizontalAlignment: TextHorizontalAlignment; + verticalAlignment: TextVerticalAlignment; + enableWrapping: boolean; + overflowMode: OverflowMode; + _layout: ISpriteLayout; + _subFont: SubFont; + _getChunkManager(): PrimitiveChunkManager; + _getSubFont(): SubFont; + _createLayout(): ISpriteLayout; + _getTextAlpha(): number; + _submitText(context: RenderContext, material: Material): void; + _isTextHostInvisible(): boolean; + _isContainDirtyFlag(type: number): boolean; + _setDirtyFlagTrue(type: number): void; + _setDirtyFlagFalse(type: number): void; + _getTextChunks(): TextChunk[]; + _getTextTextureProperty(): ShaderProperty; + _initTextRenderable(): void; +} + +/** + * Wiring mixin that provides shared text rendering logic for both 2D TextRenderer and UI Text. + */ +export function TextRenderable( + Base: T +): (abstract new (...args: any[]) => ITextRenderable) & T { + abstract class TextRenderableHost extends Base implements ITextRenderer { + private static _textureProperty = ShaderProperty.getByName("renderElement_TextTexture"); + private static _tempVec30 = new Vector3(); + private static _tempVec31 = new Vector3(); + private static _worldPositions = [new Vector3(), new Vector3(), new Vector3(), new Vector3()]; + private static _charRenderInfos: CharRenderInfo[] = []; + + /** @internal */ + @ignoreClone + _layout: ISpriteLayout; + @ignoreClone + private _textChunks = Array(); + /** @internal */ + @assignmentClone + _subFont: SubFont = null; + @ignoreClone + private _localBounds = new BoundingBox(); + @assignmentClone + private _text = ""; + @assignmentClone + private _font: Font = null; + @assignmentClone + private _fontSize = 24; + @assignmentClone + private _fontStyle = FontStyle.None; + @assignmentClone + private _lineSpacing = 0; + @assignmentClone + private _characterSpacing = 0; + @assignmentClone + private _horizontalAlignment = TextHorizontalAlignment.Center; + @assignmentClone + private _verticalAlignment = TextVerticalAlignment.Center; + @assignmentClone + private _enableWrapping = false; + @assignmentClone + private _overflowMode = OverflowMode.Overflow; + + // ===== Abstract methods ===== + + abstract get color(): Color; + abstract _getChunkManager(): PrimitiveChunkManager; + abstract _createLayout(): ISpriteLayout; + abstract _submitText(context: RenderContext, material: Material): void; + + // ===== Methods with defaults ===== + + _getTextAlpha(): number { + return 1; + } + + _isTextHostInvisible(): boolean { + return false; + } + + // ===== Text properties ===== + + get text(): string { + return this._text; + } + + set text(value: string) { + value = value || ""; + if (this._text !== value) { + this._text = value; + this._setDirtyFlagTrue(TextRenderableFlags.Position); + } + } + + get font(): Font { + return this._font; + } + + set font(value: Font) { + const lastFont = this._font; + if (lastFont !== value) { + lastFont && this._addResourceReferCount(lastFont, -1); + value && this._addResourceReferCount(value, 1); + this._font = value; + this._setDirtyFlagTrue(TextRenderableFlags.Font); + } + } + + get fontSize(): number { + return this._fontSize; + } + + set fontSize(value: number) { + if (this._fontSize !== value) { + this._fontSize = value; + this._setDirtyFlagTrue(TextRenderableFlags.Font); + } + } + + get fontStyle(): FontStyle { + return this._fontStyle; + } + + set fontStyle(value: FontStyle) { + if (this._fontStyle !== value) { + this._fontStyle = value; + this._setDirtyFlagTrue(TextRenderableFlags.Font); + } + } + + get lineSpacing(): number { + return this._lineSpacing; + } + + set lineSpacing(value: number) { + if (this._lineSpacing !== value) { + this._lineSpacing = value; + this._setDirtyFlagTrue(TextRenderableFlags.Position); + } + } + + get characterSpacing(): number { + return this._characterSpacing; + } + + set characterSpacing(value: number) { + if (this._characterSpacing !== value) { + this._characterSpacing = value; + this._setDirtyFlagTrue(TextRenderableFlags.Position); + } + } + + get horizontalAlignment(): TextHorizontalAlignment { + return this._horizontalAlignment; + } + + set horizontalAlignment(value: TextHorizontalAlignment) { + if (this._horizontalAlignment !== value) { + this._horizontalAlignment = value; + this._setDirtyFlagTrue(TextRenderableFlags.Position); + } + } + + get verticalAlignment(): TextVerticalAlignment { + return this._verticalAlignment; + } + + set verticalAlignment(value: TextVerticalAlignment) { + if (this._verticalAlignment !== value) { + this._verticalAlignment = value; + this._setDirtyFlagTrue(TextRenderableFlags.Position); + } + } + + get enableWrapping(): boolean { + return this._enableWrapping; + } + + set enableWrapping(value: boolean) { + if (this._enableWrapping !== value) { + this._enableWrapping = value; + this._setDirtyFlagTrue(TextRenderableFlags.Position); + } + } + + get overflowMode(): OverflowMode { + return this._overflowMode; + } + + set overflowMode(value: OverflowMode) { + if (this._overflowMode !== value) { + this._overflowMode = value; + this._setDirtyFlagTrue(TextRenderableFlags.Position); + } + } + + // ===== Bounds ===== + + override get bounds(): BoundingBox { + if (this._isTextNoVisible()) { + if (this._isContainDirtyFlag(RendererUpdateFlags.WorldVolume)) { + this._localBounds.min.set(0, 0, 0); + this._localBounds.max.set(0, 0, 0); + this._updateBounds(this._bounds); + this._setDirtyFlagFalse(RendererUpdateFlags.WorldVolume); + } + return this._bounds; + } + this._isContainDirtyFlag(TextRenderableFlags.SubFont) && this._resetSubFont(); + this._isContainDirtyFlag(TextRenderableFlags.LocalPositionBounds) && this._updateLocalData(); + this._isContainDirtyFlag(TextRenderableFlags.WorldPosition) && this._updatePosition(); + this._isContainDirtyFlag(RendererUpdateFlags.WorldVolume) && this._updateBounds(this._bounds); + this._setDirtyFlagFalse(TextRenderableFlags.Font); + + return this._bounds; + } + + // ===== Init ===== + + _initTextRenderable(): void { + this.font = this._engine._textDefaultFont; + this.setMaterial(this._engine._basicResources.textDefaultMaterial); + this._layout = this._createLayout(); + } + + // ===== Lifecycle ===== + + override _updateTransformShaderData(context: RenderContext, onlyMVP: boolean, batched: boolean): void { + super._updateTransformShaderData(context, onlyMVP, true); + } + + override _canBatch(elementA: SubRenderElement, elementB: SubRenderElement): boolean { + return BatchUtils.canBatchSprite(elementA, elementB); + } + + override _batch(elementA: SubRenderElement, elementB?: SubRenderElement): void { + BatchUtils.batchFor2D(elementA, elementB); + } + + // @ts-ignore + override _cloneTo(target: TextRenderableHost): void { + // @ts-ignore + super._cloneTo(target); + target.font = this._font; + target._subFont = this._subFont; + } + + protected override _updateBounds(worldBounds: BoundingBox): void { + BoundingBox.transform(this._localBounds, this._transformEntity.transform.worldMatrix, worldBounds); + } + + protected override _render(context: RenderContext): void { + if (this._isTextNoVisible()) { + return; + } + + if (this._isContainDirtyFlag(TextRenderableFlags.SubFont)) { + this._resetSubFont(); + this._setDirtyFlagFalse(TextRenderableFlags.SubFont); + } + + if (this._isContainDirtyFlag(TextRenderableFlags.LocalPositionBounds)) { + this._updateLocalData(); + this._setDirtyFlagFalse(TextRenderableFlags.LocalPositionBounds); + } + + if (this._isContainDirtyFlag(TextRenderableFlags.WorldPosition)) { + this._updatePosition(); + this._setDirtyFlagFalse(TextRenderableFlags.WorldPosition); + } + + if (this._isContainDirtyFlag(TextRenderableFlags.Color)) { + this._updateColor(); + this._setDirtyFlagFalse(TextRenderableFlags.Color); + } + + const material = this.getMaterial(); + if (!material) { + return; + } + + this._submitText(context, material); + } + + protected override _onDestroy(): void { + if (this._font) { + this._addResourceReferCount(this._font, -1); + this._font = null; + } + + super._onDestroy(); + + this._freeTextChunks(); + this._textChunks = null; + this._subFont && (this._subFont = null); + this._layout = null; + } + + @ignoreClone + protected override _onTransformChanged(type: number): void { + super._onTransformChanged(type); + this._setDirtyFlagTrue(TextRenderableFlags.WorldPosition); + } + + // ===== Shared text methods ===== + + _isContainDirtyFlag(type: number): boolean { + return (this._dirtyUpdateFlag & type) != 0; + } + + _setDirtyFlagTrue(type: number): void { + this._dirtyUpdateFlag |= type; + } + + _setDirtyFlagFalse(type: number): void { + this._dirtyUpdateFlag &= ~type; + } + + _getSubFont(): SubFont { + if (!this._subFont) { + this._resetSubFont(); + } + return this._subFont; + } + + /** @internal Accessible by hosts for submit loop. */ + _getTextChunks(): TextChunk[] { + return this._textChunks; + } + + /** @internal Texture property for sub-render element shader data. */ + _getTextTextureProperty(): ShaderProperty { + return TextRenderableHost._textureProperty; + } + + // ===== Private ===== + + private _isTextNoVisible(): boolean { + const layout = this._layout; + return ( + !this._font || + this._text === "" || + this._fontSize === 0 || + (this._enableWrapping && layout.width <= 0) || + (this._overflowMode === OverflowMode.Truncate && layout.height <= 0) || + this._isTextHostInvisible() + ); + } + + private _resetSubFont(): void { + const font = this._font; + this._subFont = font._getSubFont(this.fontSize, this.fontStyle); + this._subFont.nativeFontString = TextUtils.getNativeFontString(font.name, this.fontSize, this.fontStyle); + } + + private _updatePosition(): void { + const e = this._transformEntity.transform.worldMatrix.elements; + + // prettier-ignore + const e0 = e[0], e1 = e[1], e2 = e[2], + e4 = e[4], e5 = e[5], e6 = e[6], + e12 = e[12], e13 = e[13], e14 = e[14]; + + const up = TextRenderableHost._tempVec31.set(e4, e5, e6); + const right = TextRenderableHost._tempVec30.set(e0, e1, e2); + + const worldPositions = TextRenderableHost._worldPositions; + const worldPosition0 = worldPositions[0]; + const worldPosition1 = worldPositions[1]; + const worldPosition2 = worldPositions[2]; + const worldPosition3 = worldPositions[3]; + + const textChunks = this._textChunks; + for (let i = 0, n = textChunks.length; i < n; ++i) { + const { subChunk, charRenderInfos } = textChunks[i]; + for (let j = 0, m = charRenderInfos.length; j < m; ++j) { + const charRenderInfo = charRenderInfos[j]; + const { localPositions } = charRenderInfo; + const { x: topLeftX, y: topLeftY } = localPositions; + + // Top-Left + worldPosition0.set( + topLeftX * e0 + topLeftY * e4 + e12, + topLeftX * e1 + topLeftY * e5 + e13, + topLeftX * e2 + topLeftY * e6 + e14 + ); + + // Right offset + Vector3.scale(right, localPositions.z - topLeftX, worldPosition1); + // Top-Right + Vector3.add(worldPosition0, worldPosition1, worldPosition1); + // Up offset + Vector3.scale(up, localPositions.w - topLeftY, worldPosition2); + // Bottom-Left + Vector3.add(worldPosition0, worldPosition2, worldPosition3); + // Bottom-Right + Vector3.add(worldPosition1, worldPosition2, worldPosition2); + + const vertices = subChunk.chunk.vertices; + for (let k = 0, o = subChunk.vertexArea.start + charRenderInfo.indexInChunk * 36; k < 4; ++k, o += 9) { + worldPositions[k].copyToArray(vertices, o); + } + } + } + } + + private _updateColor(): void { + const { r, g, b, a } = this.color; + const finalAlpha = a * this._getTextAlpha(); + const textChunks = this._textChunks; + for (let i = 0, n = textChunks.length; i < n; ++i) { + const subChunk = textChunks[i].subChunk; + const vertexArea = subChunk.vertexArea; + const vertexCount = vertexArea.size / 9; + const vertices = subChunk.chunk.vertices; + for (let j = 0, o = vertexArea.start + 5; j < vertexCount; ++j, o += 9) { + vertices[o] = r; + vertices[o + 1] = g; + vertices[o + 2] = b; + vertices[o + 3] = finalAlpha; + } + } + } + + private _updateLocalData(): void { + const layout = this._layout; + let rendererWidth = layout.width; + let rendererHeight = layout.height; + const { pivot } = layout; + const resPerUnit = layout.referenceResolutionPerUnit; + const pixelsPerUnit = resPerUnit ? Engine._pixelsPerUnit / resPerUnit : Engine._pixelsPerUnit; + const offsetWidth = rendererWidth * (0.5 - pivot.x); + const offsetHeight = rendererHeight * (0.5 - pivot.y); + + const { min, max } = this._localBounds; + const charRenderInfos = TextRenderableHost._charRenderInfos; + const charFont = this._getSubFont(); + const characterSpacing = this._characterSpacing * this._fontSize; + const textMetrics = this._enableWrapping + ? TextUtils.measureTextWithWrap( + this, + rendererWidth * pixelsPerUnit, + rendererHeight * pixelsPerUnit, + this._lineSpacing * this._fontSize, + characterSpacing + ) + : TextUtils.measureTextWithoutWrap( + this, + rendererHeight * pixelsPerUnit, + this._lineSpacing * this._fontSize, + characterSpacing + ); + const { height, lines, lineWidths, lineHeight, lineMaxSizes } = textMetrics; + const charRenderInfoPool = this.engine._charRenderInfoPool; + const linesLen = lines.length; + let renderElementCount = 0; + + if (linesLen > 0) { + const { horizontalAlignment } = this; + const pixelsPerUnitReciprocal = 1.0 / pixelsPerUnit; + rendererWidth *= pixelsPerUnit; + rendererHeight *= pixelsPerUnit; + const halfRendererWidth = rendererWidth * 0.5; + const halfLineHeight = lineHeight * 0.5; + + let startY = 0; + const topDiff = lineHeight * 0.5 - lineMaxSizes[0].ascent; + const bottomDiff = lineHeight * 0.5 - lineMaxSizes[linesLen - 1].descent - 1; + switch (this.verticalAlignment) { + case TextVerticalAlignment.Top: + startY = rendererHeight * 0.5 - halfLineHeight + topDiff; + break; + case TextVerticalAlignment.Center: + startY = height * 0.5 - halfLineHeight - (bottomDiff - topDiff) * 0.5; + break; + case TextVerticalAlignment.Bottom: + startY = height - rendererHeight * 0.5 - halfLineHeight - bottomDiff; + break; + } + + let firstLine = -1; + let minX = Number.MAX_SAFE_INTEGER; + let minY = Number.MAX_SAFE_INTEGER; + let maxX = Number.MIN_SAFE_INTEGER; + let maxY = Number.MIN_SAFE_INTEGER; + for (let i = 0; i < linesLen; ++i) { + const lineWidth = lineWidths[i]; + if (lineWidth > 0) { + const line = lines[i]; + let startX = 0; + let firstRow = -1; + if (firstLine < 0) { + firstLine = i; + } + switch (horizontalAlignment) { + case TextHorizontalAlignment.Left: + startX = -halfRendererWidth; + break; + case TextHorizontalAlignment.Center: + startX = -lineWidth * 0.5; + break; + case TextHorizontalAlignment.Right: + startX = halfRendererWidth - lineWidth; + break; + } + for (let j = 0, n = line.length; j < n; ++j) { + const char = line[j]; + const charInfo = charFont._getCharInfo(char); + if (charInfo.h > 0) { + firstRow < 0 && (firstRow = j); + const charRenderInfo = (charRenderInfos[renderElementCount++] = charRenderInfoPool.get()); + const { localPositions } = charRenderInfo; + charRenderInfo.texture = charFont._getTextureByIndex(charInfo.index); + charRenderInfo.uvs = charInfo.uvs; + const { w, ascent, descent } = charInfo; + const left = (startX + offsetWidth) * pixelsPerUnitReciprocal; + const right = (startX + w + offsetWidth) * pixelsPerUnitReciprocal; + const top = (startY + ascent + offsetHeight) * pixelsPerUnitReciprocal; + const bottom = (startY - descent + offsetHeight) * pixelsPerUnitReciprocal; + localPositions.set(left, top, right, bottom); + i === firstLine && (maxY = Math.max(maxY, top)); + minY = Math.min(minY, bottom); + j === firstRow && (minX = Math.min(minX, left)); + maxX = Math.max(maxX, right); + } + startX += charInfo.xAdvance + characterSpacing; + } + } + startY -= lineHeight; + } + if (firstLine < 0) { + min.set(0, 0, 0); + max.set(0, 0, 0); + } else { + min.set(minX, minY, 0); + max.set(maxX, maxY, 0); + } + } else { + min.set(0, 0, 0); + max.set(0, 0, 0); + } + + charFont._getLastIndex() > 0 && + charRenderInfos.sort((a, b) => { + return a.texture.instanceId - b.texture.instanceId; + }); + + this._freeTextChunks(); + + if (renderElementCount === 0) { + return; + } + + const textChunks = this._textChunks; + let curTextChunk = new TextChunk(); + textChunks.push(curTextChunk); + + const chunkMaxVertexCount = this._getChunkManager().maxVertexCount; + const curCharRenderInfo = charRenderInfos[0]; + let curTexture = curCharRenderInfo.texture; + curTextChunk.texture = curTexture; + let curCharInfos = curTextChunk.charRenderInfos; + curCharInfos.push(curCharRenderInfo); + + for (let i = 1; i < renderElementCount; ++i) { + const charRenderInfo = charRenderInfos[i]; + const texture = charRenderInfo.texture; + if (curTexture !== texture || curCharInfos.length * 4 + 4 > chunkMaxVertexCount) { + this._buildChunk(curTextChunk, curCharInfos.length); + + curTextChunk = new TextChunk(); + textChunks.push(curTextChunk); + curTexture = texture; + curTextChunk.texture = texture; + curCharInfos = curTextChunk.charRenderInfos; + } + curCharInfos.push(charRenderInfo); + } + const charLength = curCharInfos.length; + if (charLength > 0) { + this._buildChunk(curTextChunk, charLength); + } + charRenderInfos.length = 0; + } + + private _buildChunk(textChunk: TextChunk, count: number): SubPrimitiveChunk { + const { r, g, b, a } = this.color; + const finalAlpha = a * this._getTextAlpha(); + const tempIndices = CharRenderInfo.triangles; + const tempIndicesLength = tempIndices.length; + const subChunk = (textChunk.subChunk = this._getChunkManager().allocateSubChunk(count * 4)); + const vertices = subChunk.chunk.vertices; + const indices = (subChunk.indices = []); + const charRenderInfos = textChunk.charRenderInfos; + for (let i = 0, ii = 0, io = 0, vo = subChunk.vertexArea.start + 3; i < count; ++i, io += 4) { + const charRenderInfo = charRenderInfos[i]; + charRenderInfo.indexInChunk = i; + + // Set indices + for (let j = 0; j < tempIndicesLength; ++j) { + indices[ii++] = tempIndices[j] + io; + } + + // Set uv and color for vertices + for (let j = 0; j < 4; ++j, vo += 9) { + const uv = charRenderInfo.uvs[j]; + uv.copyToArray(vertices, vo); + vertices[vo + 2] = r; + vertices[vo + 3] = g; + vertices[vo + 4] = b; + vertices[vo + 5] = finalAlpha; + } + } + + return subChunk; + } + + private _freeTextChunks(): void { + const textChunks = this._textChunks; + const charRenderInfoPool = this.engine._charRenderInfoPool; + const manager = this._getChunkManager(); + for (let i = 0, n = textChunks.length; i < n; ++i) { + const textChunk = textChunks[i]; + const { charRenderInfos } = textChunk; + for (let j = 0, m = charRenderInfos.length; j < m; ++j) { + charRenderInfoPool.return(charRenderInfos[j]); + } + charRenderInfos.length = 0; + manager.freeSubChunk(textChunk.subChunk); + textChunk.subChunk = null; + textChunk.texture = null; + } + textChunks.length = 0; + } + } + + return TextRenderableHost as unknown as (abstract new (...args: any[]) => ITextRenderable) & T; +} + +/** @internal */ +export class TextChunk { + charRenderInfos = new Array(); + subChunk: SubPrimitiveChunk; + texture: Texture2D; +} diff --git a/packages/core/src/2d/text/TextRenderer.ts b/packages/core/src/2d/text/TextRenderer.ts index 143e46a7da..b85c4c4259 100644 --- a/packages/core/src/2d/text/TextRenderer.ts +++ b/packages/core/src/2d/text/TextRenderer.ts @@ -1,74 +1,24 @@ -import { BoundingBox, Color, Vector3 } from "@galacean/engine-math"; -import { Engine } from "../../Engine"; +import { Color } from "@galacean/engine-math"; import { Entity } from "../../Entity"; -import { BatchUtils } from "../../RenderPipeline/BatchUtils"; import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManager"; import { RenderContext } from "../../RenderPipeline/RenderContext"; -import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; -import { SubRenderElement } from "../../RenderPipeline/SubRenderElement"; import { Renderer } from "../../Renderer"; -import { TransformModifyFlags } from "../../Transform"; -import { assignmentClone, deepClone, ignoreClone } from "../../clone/CloneManager"; -import { ShaderData, ShaderProperty } from "../../shader"; +import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { SpriteMaskLayer } from "../../enums/SpriteMaskLayer"; +import { ShaderData } from "../../shader"; import { ShaderDataGroup } from "../../shader/enums/ShaderDataGroup"; -import { Texture2D } from "../../texture"; -import { FontStyle } from "../enums/FontStyle"; import { SpriteMaskInteraction } from "../enums/SpriteMaskInteraction"; -import { TextHorizontalAlignment, TextVerticalAlignment } from "../enums/TextAlignment"; -import { OverflowMode } from "../enums/TextOverflow"; -import { CharRenderInfo } from "./CharRenderInfo"; -import { Font } from "./Font"; -import { ITextRenderer } from "./ITextRenderer"; -import { SubFont } from "./SubFont"; -import { TextUtils } from "./TextUtils"; -import { SpriteMaskLayer } from "../../enums/SpriteMaskLayer"; +import { ISpriteLayout } from "../sprite/ISpriteLayout"; +import { Material } from "../../material"; +import { TextChunk, TextRenderable, TextRenderableFlags } from "./TextRenderable"; +import { WorldTextLayout } from "./WorldTextLayout"; /** * Renders a text for 2D graphics. */ -export class TextRenderer extends Renderer implements ITextRenderer { - private static _textureProperty = ShaderProperty.getByName("renderElement_TextTexture"); - private static _tempVec30 = new Vector3(); - private static _tempVec31 = new Vector3(); - private static _worldPositions = [new Vector3(), new Vector3(), new Vector3(), new Vector3()]; - private static _charRenderInfos: CharRenderInfo[] = []; - - @ignoreClone - private _textChunks = Array(); - /** @internal */ - @assignmentClone - _subFont: SubFont = null; - /** @internal */ - @ignoreClone - _dirtyFlag = DirtyFlag.Font; +export class TextRenderer extends TextRenderable(Renderer) { @deepClone private _color = new Color(1, 1, 1, 1); - @assignmentClone - private _text = ""; - @assignmentClone - private _width = 0; - @assignmentClone - private _height = 0; - @ignoreClone - private _localBounds = new BoundingBox(); - @assignmentClone - private _font: Font = null; - @assignmentClone - private _fontSize = 24; - @assignmentClone - private _fontStyle = FontStyle.None; - @assignmentClone - private _lineSpacing = 0; - @assignmentClone - private _characterSpacing = 0; - @assignmentClone - private _horizontalAlignment = TextHorizontalAlignment.Center; - @assignmentClone - private _verticalAlignment = TextVerticalAlignment.Center; - @assignmentClone - private _enableWrapping = false; - @assignmentClone - private _overflowMode = OverflowMode.Overflow; /** * Rendering color for the Text. @@ -83,32 +33,18 @@ export class TextRenderer extends Renderer implements ITextRenderer { } } - /** - * Rendering string for the Text. - */ - get text(): string { - return this._text; - } - - set text(value: string) { - value = value || ""; - if (this._text !== value) { - this._text = value; - this._setDirtyFlagTrue(DirtyFlag.Position); - } - } - /** * The width of the TextRenderer (in 3D world coordinates). */ get width(): number { - return this._width; + return (this._layout).width; } set width(value: number) { - if (this._width !== value) { - this._width = value; - this._setDirtyFlagTrue(DirtyFlag.Position); + const layout = this._layout; + if (layout.width !== value) { + layout.width = value; + this._setDirtyFlagTrue(TextRenderableFlags.Position); } } @@ -116,142 +52,14 @@ export class TextRenderer extends Renderer implements ITextRenderer { * The height of the TextRenderer (in 3D world coordinates). */ get height(): number { - return this._height; + return (this._layout).height; } set height(value: number) { - if (this._height !== value) { - this._height = value; - this._setDirtyFlagTrue(DirtyFlag.Position); - } - } - - /** - * The font of the Text. - */ - get font(): Font { - return this._font; - } - - set font(value: Font) { - const lastFont = this._font; - if (lastFont !== value) { - lastFont && this._addResourceReferCount(lastFont, -1); - value && this._addResourceReferCount(value, 1); - this._font = value; - this._setDirtyFlagTrue(DirtyFlag.Font); - } - } - - /** - * The font size of the Text. - */ - get fontSize(): number { - return this._fontSize; - } - - set fontSize(value: number) { - if (this._fontSize !== value) { - this._fontSize = value; - this._setDirtyFlagTrue(DirtyFlag.Font); - } - } - - /** - * The style of the font. - */ - get fontStyle(): FontStyle { - return this._fontStyle; - } - - set fontStyle(value: FontStyle) { - if (this.fontStyle !== value) { - this._fontStyle = value; - this._setDirtyFlagTrue(DirtyFlag.Font); - } - } - - /** - * The space between two lines, in em (ratio of fontSize). - */ - get lineSpacing(): number { - return this._lineSpacing; - } - - set lineSpacing(value: number) { - if (this._lineSpacing !== value) { - this._lineSpacing = value; - this._setDirtyFlagTrue(DirtyFlag.Position); - } - } - - /** - * The space between two characters, in em (ratio of fontSize). - */ - get characterSpacing(): number { - return this._characterSpacing; - } - - set characterSpacing(value: number) { - if (this._characterSpacing !== value) { - this._characterSpacing = value; - this._setDirtyFlagTrue(DirtyFlag.Position); - } - } - - /** - * The horizontal alignment. - */ - get horizontalAlignment(): TextHorizontalAlignment { - return this._horizontalAlignment; - } - - set horizontalAlignment(value: TextHorizontalAlignment) { - if (this._horizontalAlignment !== value) { - this._horizontalAlignment = value; - this._setDirtyFlagTrue(DirtyFlag.Position); - } - } - - /** - * The vertical alignment. - */ - get verticalAlignment(): TextVerticalAlignment { - return this._verticalAlignment; - } - - set verticalAlignment(value: TextVerticalAlignment) { - if (this._verticalAlignment !== value) { - this._verticalAlignment = value; - this._setDirtyFlagTrue(DirtyFlag.Position); - } - } - - /** - * Whether wrap text to next line when exceeds the width of the container. - */ - get enableWrapping(): boolean { - return this._enableWrapping; - } - - set enableWrapping(value: boolean) { - if (this._enableWrapping !== value) { - this._enableWrapping = value; - this._setDirtyFlagTrue(DirtyFlag.Position); - } - } - - /** - * The overflow mode. - */ - get overflowMode(): OverflowMode { - return this._overflowMode; - } - - set overflowMode(value: OverflowMode) { - if (this._overflowMode !== value) { - this._overflowMode = value; - this._setDirtyFlagTrue(DirtyFlag.Position); + const layout = this._layout; + if (layout.height !== value) { + layout.height = value; + this._setDirtyFlagTrue(TextRenderableFlags.Position); } } @@ -279,485 +87,47 @@ export class TextRenderer extends Renderer implements ITextRenderer { this._maskLayer = value; } - /** - * The bounding volume of the TextRenderer. - */ - override get bounds(): BoundingBox { - if (this._isTextNoVisible()) { - if (this._isContainDirtyFlag(DirtyFlag.WorldBounds)) { - const localBounds = this._localBounds; - localBounds.min.set(0, 0, 0); - localBounds.max.set(0, 0, 0); - this._updateBounds(this._bounds); - this._setDirtyFlagFalse(DirtyFlag.WorldBounds); - } - return this._bounds; - } - this._isContainDirtyFlag(DirtyFlag.SubFont) && this._resetSubFont(); - this._isContainDirtyFlag(DirtyFlag.LocalPositionBounds) && this._updateLocalData(); - this._isContainDirtyFlag(DirtyFlag.WorldPosition) && this._updatePosition(); - this._isContainDirtyFlag(DirtyFlag.WorldBounds) && this._updateBounds(this._bounds); - this._setDirtyFlagFalse(DirtyFlag.Font); - - return this._bounds; - } - constructor(entity: Entity) { super(entity); - - const { engine } = this; - this._font = engine._textDefaultFont; - this._addResourceReferCount(this._font, 1); - this.setMaterial(engine._basicResources.textDefaultMaterial); + this._initTextRenderable(); + this._dirtyUpdateFlag |= TextRenderableFlags.Font; //@ts-ignore this._color._onValueChanged = this._onColorChanged.bind(this); } - /** - * @internal - */ - protected override _onDestroy(): void { - if (this._font) { - this._addResourceReferCount(this._font, -1); - this._font = null; - } - - super._onDestroy(); - - this._freeTextChunks(); - this._textChunks = null; - - this._subFont && (this._subFont = null); - } - - /** - * @internal - */ - override _cloneTo(target: TextRenderer): void { - super._cloneTo(target); - target.font = this._font; - target._subFont = this._subFont; - } + // ===== Abstract implementations ===== - /** - * @internal - */ - _isContainDirtyFlag(type: number): boolean { - return (this._dirtyFlag & type) != 0; - } - - /** - * @internal - */ - _setDirtyFlagTrue(type: number): void { - this._dirtyFlag |= type; - } - - /** - * @internal - */ - _setDirtyFlagFalse(type: number): void { - this._dirtyFlag &= ~type; - } - - /** - * @internal - */ - _getSubFont(): SubFont { - if (!this._subFont) { - this._resetSubFont(); - } - return this._subFont; - } - - /** - * @internal - */ - override _updateTransformShaderData(context: RenderContext, onlyMVP: boolean, batched: boolean): void { - //@todo: Always update world positions to buffer, should opt - super._updateTransformShaderData(context, onlyMVP, true); - } - - /** - * @internal - */ - override _canBatch(elementA: SubRenderElement, elementB: SubRenderElement): boolean { - return BatchUtils.canBatchSprite(elementA, elementB); - } - - /** - * @internal - */ - override _batch(elementA: SubRenderElement, elementB?: SubRenderElement): void { - BatchUtils.batchFor2D(elementA, elementB); - } - - /** - * @internal - */ - _getChunkManager(): PrimitiveChunkManager { + override _getChunkManager(): PrimitiveChunkManager { return this.engine._batcherManager.primitiveChunkManager2D; } - protected override _updateBounds(worldBounds: BoundingBox): void { - BoundingBox.transform(this._localBounds, this._entity.transform.worldMatrix, worldBounds); + override _createLayout(): ISpriteLayout { + return new WorldTextLayout(); } - protected override _render(context: RenderContext): void { - if (this._isTextNoVisible()) { - return; - } - - if (this._isContainDirtyFlag(DirtyFlag.SubFont)) { - this._resetSubFont(); - this._setDirtyFlagFalse(DirtyFlag.SubFont); - } - - if (this._isContainDirtyFlag(DirtyFlag.LocalPositionBounds)) { - this._updateLocalData(); - this._setDirtyFlagFalse(DirtyFlag.LocalPositionBounds); - } - - if (this._isContainDirtyFlag(DirtyFlag.WorldPosition)) { - this._updatePosition(); - this._setDirtyFlagFalse(DirtyFlag.WorldPosition); - } - - if (this._isContainDirtyFlag(DirtyFlag.Color)) { - this._updateColor(); - this._setDirtyFlagFalse(DirtyFlag.Color); - } - + override _submitText(context: RenderContext, material: Material): void { const camera = context.camera; const engine = camera.engine; const textSubRenderElementPool = engine._textSubRenderElementPool; - const material = this.getMaterial(); const renderElement = engine._renderElementPool.get(); renderElement.set(this.priority, this._distanceForSort); - const textChunks = this._textChunks; + const textChunks = this._getTextChunks(); + const textureProperty = this._getTextTextureProperty(); for (let i = 0, n = textChunks.length; i < n; ++i) { const { subChunk, texture } = textChunks[i]; const subRenderElement = textSubRenderElementPool.get(); subRenderElement.set(this, material, subChunk.chunk.primitive, subChunk.subMesh, texture, subChunk); subRenderElement.shaderData ||= new ShaderData(ShaderDataGroup.RenderElement); - subRenderElement.shaderData.setTexture(TextRenderer._textureProperty, texture); + subRenderElement.shaderData.setTexture(textureProperty, texture); renderElement.addSubRenderElement(subRenderElement); } camera._renderPipeline.pushRenderElement(context, renderElement); } - private _resetSubFont(): void { - const font = this._font; - this._subFont = font._getSubFont(this.fontSize, this.fontStyle); - this._subFont.nativeFontString = TextUtils.getNativeFontString(font.name, this.fontSize, this.fontStyle); - } - - private _updatePosition(): void { - const { transform } = this.entity; - const e = transform.worldMatrix.elements; - - // prettier-ignore - const e0 = e[0], e1 = e[1], e2 = e[2], - e4 = e[4], e5 = e[5], e6 = e[6], - e12 = e[12], e13 = e[13], e14 = e[14]; - - const up = TextRenderer._tempVec31.set(e4, e5, e6); - const right = TextRenderer._tempVec30.set(e0, e1, e2); - - const worldPositions = TextRenderer._worldPositions; - const worldPosition0 = worldPositions[0]; - const worldPosition1 = worldPositions[1]; - const worldPosition2 = worldPositions[2]; - const worldPosition3 = worldPositions[3]; - - const textChunks = this._textChunks; - for (let i = 0, n = textChunks.length; i < n; ++i) { - const { subChunk, charRenderInfos } = textChunks[i]; - for (let j = 0, m = charRenderInfos.length; j < m; ++j) { - const charRenderInfo = charRenderInfos[j]; - const { localPositions } = charRenderInfo; - const { x: topLeftX, y: topLeftY } = localPositions; - - // Top-Left - worldPosition0.set( - topLeftX * e0 + topLeftY * e4 + e12, - topLeftX * e1 + topLeftY * e5 + e13, - topLeftX * e2 + topLeftY * e6 + e14 - ); - - // Right offset - Vector3.scale(right, localPositions.z - topLeftX, worldPosition1); - // Top-Right - Vector3.add(worldPosition0, worldPosition1, worldPosition1); - // Up offset - Vector3.scale(up, localPositions.w - topLeftY, worldPosition2); - // Bottom-Left - Vector3.add(worldPosition0, worldPosition2, worldPosition3); - // Bottom-Right - Vector3.add(worldPosition1, worldPosition2, worldPosition2); - - const vertices = subChunk.chunk.vertices; - for (let k = 0, o = subChunk.vertexArea.start + charRenderInfo.indexInChunk * 36; k < 4; ++k, o += 9) { - worldPositions[k].copyToArray(vertices, o); - } - } - } - } - - private _updateColor(): void { - const { r, g, b, a } = this._color; - const textChunks = this._textChunks; - for (let i = 0, n = textChunks.length; i < n; ++i) { - const subChunk = textChunks[i].subChunk; - const vertexArea = subChunk.vertexArea; - const vertexCount = vertexArea.size / 9; - const vertices = subChunk.chunk.vertices; - for (let j = 0, o = vertexArea.start + 5; j < vertexCount; ++j, o += 9) { - vertices[o] = r; - vertices[o + 1] = g; - vertices[o + 2] = b; - vertices[o + 3] = a; - } - } - } - - private _updateLocalData(): void { - const { _pixelsPerUnit } = Engine; - const { min, max } = this._localBounds; - const charRenderInfos = TextRenderer._charRenderInfos; - const charFont = this._getSubFont(); - const characterSpacing = this._characterSpacing * this._fontSize; - const textMetrics = this.enableWrapping - ? TextUtils.measureTextWithWrap( - this, - this.width * _pixelsPerUnit, - this.height * _pixelsPerUnit, - this._lineSpacing * this._fontSize, - characterSpacing - ) - : TextUtils.measureTextWithoutWrap( - this, - this.height * _pixelsPerUnit, - this._lineSpacing * this._fontSize, - characterSpacing - ); - const { height, lines, lineWidths, lineHeight, lineMaxSizes } = textMetrics; - const charRenderInfoPool = this.engine._charRenderInfoPool; - const linesLen = lines.length; - let renderElementCount = 0; - - if (linesLen > 0) { - const { horizontalAlignment } = this; - const pixelsPerUnitReciprocal = 1.0 / _pixelsPerUnit; - const rendererWidth = this._width * _pixelsPerUnit; - const halfRendererWidth = rendererWidth * 0.5; - const rendererHeight = this._height * _pixelsPerUnit; - const halfLineHeight = lineHeight * 0.5; - - let startY = 0; - const topDiff = lineHeight * 0.5 - lineMaxSizes[0].ascent; - const bottomDiff = lineHeight * 0.5 - lineMaxSizes[linesLen - 1].descent - 1; - switch (this.verticalAlignment) { - case TextVerticalAlignment.Top: - startY = rendererHeight * 0.5 - halfLineHeight + topDiff; - break; - case TextVerticalAlignment.Center: - startY = height * 0.5 - halfLineHeight - (bottomDiff - topDiff) * 0.5; - break; - case TextVerticalAlignment.Bottom: - startY = height - rendererHeight * 0.5 - halfLineHeight - bottomDiff; - break; - } - - let firstLine = -1; - let minX = Number.MAX_SAFE_INTEGER; - let minY = Number.MAX_SAFE_INTEGER; - let maxX = Number.MIN_SAFE_INTEGER; - let maxY = Number.MIN_SAFE_INTEGER; - for (let i = 0; i < linesLen; ++i) { - const lineWidth = lineWidths[i]; - if (lineWidth > 0) { - const line = lines[i]; - let startX = 0; - let firstRow = -1; - if (firstLine < 0) { - firstLine = i; - } - switch (horizontalAlignment) { - case TextHorizontalAlignment.Left: - startX = -halfRendererWidth; - break; - case TextHorizontalAlignment.Center: - startX = -lineWidth * 0.5; - break; - case TextHorizontalAlignment.Right: - startX = halfRendererWidth - lineWidth; - break; - } - for (let j = 0, n = line.length; j < n; ++j) { - const char = line[j]; - const charInfo = charFont._getCharInfo(char); - if (charInfo.h > 0) { - firstRow < 0 && (firstRow = j); - const charRenderInfo = (charRenderInfos[renderElementCount++] = charRenderInfoPool.get()); - const { localPositions } = charRenderInfo; - charRenderInfo.texture = charFont._getTextureByIndex(charInfo.index); - charRenderInfo.uvs = charInfo.uvs; - const { w, ascent, descent } = charInfo; - const left = startX * pixelsPerUnitReciprocal; - const right = (startX + w) * pixelsPerUnitReciprocal; - const top = (startY + ascent) * pixelsPerUnitReciprocal; - const bottom = (startY - descent) * pixelsPerUnitReciprocal; - localPositions.set(left, top, right, bottom); - i === firstLine && (maxY = Math.max(maxY, top)); - minY = Math.min(minY, bottom); - j === firstRow && (minX = Math.min(minX, left)); - maxX = Math.max(maxX, right); - } - startX += charInfo.xAdvance + characterSpacing; - } - } - startY -= lineHeight; - } - if (firstLine < 0) { - min.set(0, 0, 0); - max.set(0, 0, 0); - } else { - min.set(minX, minY, 0); - max.set(maxX, maxY, 0); - } - } else { - min.set(0, 0, 0); - max.set(0, 0, 0); - } - - charFont._getLastIndex() > 0 && - charRenderInfos.sort((a, b) => { - return a.texture.instanceId - b.texture.instanceId; - }); - - this._freeTextChunks(); - - if (renderElementCount === 0) { - return; - } - - const textChunks = this._textChunks; - let curTextChunk = new TextChunk(); - textChunks.push(curTextChunk); - - const chunkMaxVertexCount = this._getChunkManager().maxVertexCount; - const curCharRenderInfo = charRenderInfos[0]; - let curTexture = curCharRenderInfo.texture; - curTextChunk.texture = curTexture; - let curCharInfos = curTextChunk.charRenderInfos; - curCharInfos.push(curCharRenderInfo); - - for (let i = 1; i < renderElementCount; ++i) { - const charRenderInfo = charRenderInfos[i]; - const texture = charRenderInfo.texture; - if (curTexture !== texture || curCharInfos.length * 4 + 4 > chunkMaxVertexCount) { - this._buildChunk(curTextChunk, curCharInfos.length); - - curTextChunk = new TextChunk(); - textChunks.push(curTextChunk); - curTexture = texture; - curTextChunk.texture = texture; - curCharInfos = curTextChunk.charRenderInfos; - } - curCharInfos.push(charRenderInfo); - } - const charLength = curCharInfos.length; - if (charLength > 0) { - this._buildChunk(curTextChunk, charLength); - } - charRenderInfos.length = 0; - } - - @ignoreClone - protected override _onTransformChanged(bit: TransformModifyFlags): void { - super._onTransformChanged(bit); - this._setDirtyFlagTrue(DirtyFlag.WorldPosition | DirtyFlag.WorldBounds); - } - - private _isTextNoVisible(): boolean { - return ( - !this._font || - this._text === "" || - this._fontSize === 0 || - (this.enableWrapping && this.width <= 0) || - (this.overflowMode === OverflowMode.Truncate && this.height <= 0) - ); - } - - private _buildChunk(textChunk: TextChunk, count: number): SubPrimitiveChunk { - const { r, g, b, a } = this.color; - const tempIndices = CharRenderInfo.triangles; - const tempIndicesLength = tempIndices.length; - const subChunk = (textChunk.subChunk = this._getChunkManager().allocateSubChunk(count * 4)); - const vertices = subChunk.chunk.vertices; - const indices = (subChunk.indices = []); - const charRenderInfos = textChunk.charRenderInfos; - for (let i = 0, ii = 0, io = 0, vo = subChunk.vertexArea.start + 3; i < count; ++i, io += 4) { - const charRenderInfo = charRenderInfos[i]; - charRenderInfo.indexInChunk = i; - - // Set indices - for (let j = 0; j < tempIndicesLength; ++j) { - indices[ii++] = tempIndices[j] + io; - } - - // Set uv and color for vertices - for (let j = 0; j < 4; ++j, vo += 9) { - const uv = charRenderInfo.uvs[j]; - uv.copyToArray(vertices, vo); - vertices[vo + 2] = r; - vertices[vo + 3] = g; - vertices[vo + 4] = b; - vertices[vo + 5] = a; - } - } - - return subChunk; - } - - private _freeTextChunks(): void { - const textChunks = this._textChunks; - const charRenderInfoPool = this.engine._charRenderInfoPool; - const manager = this._getChunkManager(); - for (let i = 0, n = textChunks.length; i < n; ++i) { - const textChunk = textChunks[i]; - const { charRenderInfos } = textChunk; - for (let j = 0, m = charRenderInfos.length; j < m; ++j) { - charRenderInfoPool.return(charRenderInfos[j]); - } - charRenderInfos.length = 0; - manager.freeSubChunk(textChunk.subChunk); - textChunk.subChunk = null; - textChunk.texture = null; - } - textChunks.length = 0; - } + // ===== Private ===== @ignoreClone private _onColorChanged(): void { - this._setDirtyFlagTrue(DirtyFlag.Color); + this._setDirtyFlagTrue(TextRenderableFlags.Color); } } - -class TextChunk { - charRenderInfos = new Array(); - subChunk: SubPrimitiveChunk; - texture: Texture2D; -} - -enum DirtyFlag { - SubFont = 0x1, - LocalPositionBounds = 0x2, - WorldPosition = 0x4, - WorldBounds = 0x8, - Color = 0x10, - - Position = LocalPositionBounds | WorldPosition | WorldBounds, - Font = SubFont | Position -} diff --git a/packages/core/src/2d/text/WorldTextLayout.ts b/packages/core/src/2d/text/WorldTextLayout.ts new file mode 100644 index 0000000000..639e2e3cb3 --- /dev/null +++ b/packages/core/src/2d/text/WorldTextLayout.ts @@ -0,0 +1,54 @@ +import { Vector2 } from "@galacean/engine-math"; +import { ISpriteLayout } from "../sprite/ISpriteLayout"; + +/** + * Layout for world-space text renderers (TextRenderer). + * Provides custom width/height, centered pivot (0.5, 0.5), no flip. + */ +export class WorldTextLayout implements ISpriteLayout { + private static _defaultPivot = new Vector2(0.5, 0.5); + private _width: number = 0; + private _height: number = 0; + + get width(): number { + return this._width; + } + + set width(value: number) { + this._width = value; + } + + get height(): number { + return this._height; + } + + set height(value: number) { + this._height = value; + } + + get pivot(): Vector2 { + return WorldTextLayout._defaultPivot; + } + + get flipX(): boolean { + return false; + } + + get flipY(): boolean { + return false; + } + + get referenceResolutionPerUnit(): number | undefined { + return undefined; + } + + onSpriteSizeChanged(): number { + return 0; + } + + onSpritePivotChanged(): number { + return 0; + } + + onSpriteInstanceChanged(): void {} +} diff --git a/packages/core/src/2d/text/index.ts b/packages/core/src/2d/text/index.ts index 91a084757d..89f6ab02ee 100644 --- a/packages/core/src/2d/text/index.ts +++ b/packages/core/src/2d/text/index.ts @@ -1,5 +1,8 @@ export { Font } from "./Font"; +export type { ITextRenderable } from "./TextRenderable"; +export { TextChunk, TextRenderable, TextRenderableFlags } from "./TextRenderable"; export { TextRenderer } from "./TextRenderer"; +export { WorldTextLayout } from "./WorldTextLayout"; // For set TextUtils._extendHeight used to extend the height of canvas, because in miniprogram performance is different from h5 export { CharRenderInfo } from "./CharRenderInfo"; export { SubFont } from "./SubFont"; diff --git a/packages/core/src/RenderPipeline/index.ts b/packages/core/src/RenderPipeline/index.ts index 7161b57757..8e32233f03 100644 --- a/packages/core/src/RenderPipeline/index.ts +++ b/packages/core/src/RenderPipeline/index.ts @@ -1,5 +1,8 @@ export { BasicRenderPipeline, RenderQueueFlags } from "./BasicRenderPipeline"; export { BatchUtils } from "./BatchUtils"; export { Blitter } from "./Blitter"; +export { PrimitiveChunkManager } from "./PrimitiveChunkManager"; +export { RenderContext } from "./RenderContext"; export { RenderQueue } from "./RenderQueue"; +export { SubPrimitiveChunk } from "./SubPrimitiveChunk"; export { PipelineStage } from "./enums/PipelineStage"; diff --git a/packages/ui/src/component/advanced/Image.ts b/packages/ui/src/component/advanced/Image.ts index b8ca6ffe55..8a21cd4f3a 100644 --- a/packages/ui/src/component/advanced/Image.ts +++ b/packages/ui/src/component/advanced/Image.ts @@ -1,149 +1,91 @@ import { BoundingBox, Entity, - ISpriteAssembler, - ISpriteRenderer, - MathUtil, + Material, + PrimitiveChunkManager, + RenderContext, RenderQueueFlags, RendererUpdateFlags, - SimpleSpriteAssembler, - SlicedSpriteAssembler, - Sprite, SpriteDrawMode, - SpriteModifyFlags, - SpriteTileMode, - TiledSpriteAssembler, - assignmentClone, + SpriteRenderable, + SpriteRenderableFlags, + SubPrimitiveChunk, + Texture2D, ignoreClone } from "@galacean/engine"; +import type { ISpriteLayout, ISpriteRenderable } from "@galacean/engine"; import { CanvasRenderMode } from "../../enums/CanvasRenderMode"; import { RootCanvasModifyFlags } from "../UICanvas"; -import { UIRenderer, UIRendererUpdateFlags } from "../UIRenderer"; +import { UIRenderer } from "../UIRenderer"; import { UITransform, UITransformModifyFlags } from "../UITransform"; +import { UISpriteLayout } from "./UISpriteLayout"; /** * UI element that renders an image. */ -export class Image extends UIRenderer implements ISpriteRenderer { - @ignoreClone - private _sprite: Sprite = null; - @ignoreClone - private _drawMode: SpriteDrawMode; - @ignoreClone - private _assembler: ISpriteAssembler; - @assignmentClone - private _tileMode: SpriteTileMode = SpriteTileMode.Continuous; - @assignmentClone - private _tiledAdaptiveThreshold: number = 0.5; - +export class Image extends SpriteRenderable(UIRenderer) { /** - * The draw mode of the image. + * @internal */ - get drawMode(): SpriteDrawMode { - return this._drawMode; + constructor(entity: Entity) { + super(entity); + this._initSpriteRenderable(UIRenderer._textureProperty); } - set drawMode(value: SpriteDrawMode) { - if (this._drawMode !== value) { - this._drawMode = value; - switch (value) { - case SpriteDrawMode.Simple: - this._assembler = SimpleSpriteAssembler; - break; - case SpriteDrawMode.Sliced: - this._assembler = SlicedSpriteAssembler; - break; - case SpriteDrawMode.Tiled: - this._assembler = TiledSpriteAssembler; - break; - default: - break; - } - this._assembler.resetData(this); - this._dirtyUpdateFlag |= ImageUpdateFlags.WorldVolumeUVAndColor; - } - } + // ===== Abstract implementations ===== - /** - * The tiling mode of the image. (Only works in tiled mode.) - */ - get tileMode(): SpriteTileMode { - return this._tileMode; + /** @internal */ + override _getDefaultSpriteMaterial(): Material { + // @ts-ignore + return this._engine._getUIDefaultMaterial(); } - set tileMode(value: SpriteTileMode) { - if (this._tileMode !== value) { - this._tileMode = value; - if (this.drawMode === SpriteDrawMode.Tiled) { - this._dirtyUpdateFlag |= ImageUpdateFlags.WorldVolumeUVAndColor; - } - } - } + /** @internal */ + override _submitSpriteRenderElement( + context: RenderContext, + material: Material, + subChunk: SubPrimitiveChunk, + texture: Texture2D + ): void { + const canvas = this._getRootCanvas(); + if (!canvas) return; - /** - * Stretch Threshold in Tile Adaptive Mode, specified in normalized. (Only works in tiled adaptive mode.) - */ - get tiledAdaptiveThreshold(): number { - return this._tiledAdaptiveThreshold; - } + const engine = context.camera.engine; + const subRenderElement = engine._subRenderElementPool.get(); + subRenderElement.set(this, material, subChunk.chunk.primitive, subChunk.subMesh, texture, subChunk); - set tiledAdaptiveThreshold(value: number) { - if (value !== this._tiledAdaptiveThreshold) { - value = MathUtil.clamp(value, 0, 1); - this._tiledAdaptiveThreshold = value; - if (this.drawMode === SpriteDrawMode.Tiled) { - this._dirtyUpdateFlag |= ImageUpdateFlags.WorldVolumeUVAndColor; - } + if (canvas._realRenderMode === CanvasRenderMode.ScreenSpaceOverlay) { + subRenderElement.shaderPasses = material.shader.subShaders[0].passes; + subRenderElement.renderQueueFlags = RenderQueueFlags.All; } - } - /** - * The Sprite to render. - */ - get sprite(): Sprite { - return this._sprite; + canvas._renderElement.addSubRenderElement(subRenderElement); } - set sprite(value: Sprite | null) { - const lastSprite = this._sprite; - if (lastSprite !== value) { - if (lastSprite) { - this._addResourceReferCount(lastSprite, -1); - // @ts-ignore - lastSprite._updateFlagManager.removeListener(this._onSpriteChange); - } - this._dirtyUpdateFlag |= ImageUpdateFlags.WorldVolumeUVAndColor; - if (value) { - this._addResourceReferCount(value, 1); - // @ts-ignore - value._updateFlagManager.addListener(this._onSpriteChange); - this.shaderData.setTexture(UIRenderer._textureProperty, value.texture); - } else { - this.shaderData.setTexture(UIRenderer._textureProperty, null); - } - this._sprite = value; - } + /** @internal */ + override _createLayout(): ISpriteLayout { + return new UISpriteLayout( + () => this._transformEntity.transform, + () => this._getRootCanvas() + ); } - /** - * @internal - */ - constructor(entity: Entity) { - super(entity); - this.drawMode = SpriteDrawMode.Simple; - // @ts-ignore - this.setMaterial(this._engine._getUIDefaultMaterial()); - this._onSpriteChange = this._onSpriteChange.bind(this); + // ===== Override defaults ===== + + override _getSpriteAlpha(): number { + return this._getGlobalAlpha(); } + // ===== Image-specific ===== + /** * @internal */ _onRootCanvasModify(flag: RootCanvasModifyFlags): void { if (flag & RootCanvasModifyFlags.ReferenceResolutionPerUnit) { - const drawMode = this._drawMode; + const drawMode = this.drawMode; if (drawMode === SpriteDrawMode.Tiled) { - this._dirtyUpdateFlag |= ImageUpdateFlags.All; + this._dirtyUpdateFlag |= SpriteRenderableFlags.All; } else if (drawMode === SpriteDrawMode.Sliced) { this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; } @@ -153,29 +95,16 @@ export class Image extends UIRenderer implements ISpriteRenderer { /** * @internal */ + // @ts-ignore _cloneTo(target: Image): void { // @ts-ignore super._cloneTo(target); - target.sprite = this._sprite; - target.drawMode = this._drawMode; } protected override _updateBounds(worldBounds: BoundingBox): void { - const sprite = this._sprite; const rootCanvas = this._getRootCanvas(); - if (sprite && rootCanvas) { - const transform = this._transformEntity.transform; - const { size } = transform; - this._assembler.updatePositions( - this, - transform.worldMatrix, - size.x, - size.y, - transform.pivot, - false, - false, - rootCanvas.referenceResolutionPerUnit - ); + if (this.sprite && rootCanvas) { + super._updateBounds(worldBounds); } else { const { worldPosition } = this._transformEntity.transform; worldBounds.min.copyFrom(worldPosition); @@ -183,146 +112,11 @@ export class Image extends UIRenderer implements ISpriteRenderer { } } - protected override _render(context): void { - const { _sprite: sprite } = this; - const transform = this._transformEntity.transform; - const { x: width, y: height } = transform.size; - if (!sprite?.texture || !width || !height) { - return; - } - - let material = this.getMaterial(); - if (!material) { - return; - } - // @todo: This question needs to be raised rather than hidden. - if (material.destroyed) { - // @ts-ignore - material = this._engine._getUIDefaultMaterial(); - } - - const alpha = this._getGlobalAlpha(); - if (this._color.a * alpha <= 0) { - return; - } - - let { _dirtyUpdateFlag: dirtyUpdateFlag } = this; - const canvas = this._getRootCanvas(); - // Update position - if (dirtyUpdateFlag & RendererUpdateFlags.WorldVolume) { - this._assembler.updatePositions( - this, - transform.worldMatrix, - width, - height, - transform.pivot, - false, - false, - canvas.referenceResolutionPerUnit - ); - dirtyUpdateFlag &= ~RendererUpdateFlags.WorldVolume; - } - - // Update uv - if (dirtyUpdateFlag & ImageUpdateFlags.UV) { - this._assembler.updateUVs(this); - dirtyUpdateFlag &= ~ImageUpdateFlags.UV; - } - - // Update color - if (dirtyUpdateFlag & UIRendererUpdateFlags.Color) { - this._assembler.updateColor(this, alpha); - dirtyUpdateFlag &= ~UIRendererUpdateFlags.Color; - } - - this._dirtyUpdateFlag = dirtyUpdateFlag; - // Init sub render element. - const { engine } = context.camera; - const subRenderElement = engine._subRenderElementPool.get(); - const subChunk = this._subChunk; - subRenderElement.set(this, material, subChunk.chunk.primitive, subChunk.subMesh, this.sprite.texture, subChunk); - if (canvas._realRenderMode === CanvasRenderMode.ScreenSpaceOverlay) { - subRenderElement.shaderPasses = material.shader.subShaders[0].passes; - subRenderElement.renderQueueFlags = RenderQueueFlags.All; - } - canvas._renderElement.addSubRenderElement(subRenderElement); - } - @ignoreClone protected override _onTransformChanged(type: number): void { - if (type & UITransformModifyFlags.Size && this._drawMode === SpriteDrawMode.Tiled) { - this._dirtyUpdateFlag |= ImageUpdateFlags.All; + if (type & UITransformModifyFlags.Size && this.drawMode === SpriteDrawMode.Tiled) { + this._dirtyUpdateFlag |= SpriteRenderableFlags.All; } this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; } - - protected override _onDestroy(): void { - const sprite = this._sprite; - if (sprite) { - this._addResourceReferCount(sprite, -1); - // @ts-ignore - sprite._updateFlagManager.removeListener(this._onSpriteChange); - this._sprite = null; - } - super._onDestroy(); - } - - @ignoreClone - private _onSpriteChange(type: SpriteModifyFlags): void { - switch (type) { - case SpriteModifyFlags.texture: - this.shaderData.setTexture(UIRenderer._textureProperty, this.sprite.texture); - break; - case SpriteModifyFlags.size: - switch (this._drawMode) { - case SpriteDrawMode.Sliced: - this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; - break; - case SpriteDrawMode.Tiled: - this._dirtyUpdateFlag |= ImageUpdateFlags.WorldVolumeUVAndColor; - break; - default: - break; - } - break; - case SpriteModifyFlags.border: - switch (this._drawMode) { - case SpriteDrawMode.Sliced: - this._dirtyUpdateFlag |= ImageUpdateFlags.WorldVolumeAndUV; - break; - case SpriteDrawMode.Tiled: - this._dirtyUpdateFlag |= ImageUpdateFlags.WorldVolumeUVAndColor; - break; - default: - break; - } - break; - case SpriteModifyFlags.region: - case SpriteModifyFlags.atlasRegionOffset: - this._dirtyUpdateFlag |= ImageUpdateFlags.WorldVolumeAndUV; - break; - case SpriteModifyFlags.atlasRegion: - this._dirtyUpdateFlag |= ImageUpdateFlags.UV; - break; - case SpriteModifyFlags.destroy: - this.sprite = null; - break; - } - } -} - -/** - * @remarks Extends `UIRendererUpdateFlags`. - */ -enum ImageUpdateFlags { - /** UV. */ - UV = 0x4, - /** Automatic Size. */ - AutomaticSize = 0x8, - /** WorldVolume and UV. */ - WorldVolumeAndUV = 0x5, - /** WorldVolume, UV and Color. */ - WorldVolumeUVAndColor = 0x7, - /** All. */ - All = 0xf } diff --git a/packages/ui/src/component/advanced/Text.ts b/packages/ui/src/component/advanced/Text.ts index 98a16436a8..0bcad904dd 100644 --- a/packages/ui/src/component/advanced/Text.ts +++ b/packages/ui/src/component/advanced/Text.ts @@ -1,212 +1,82 @@ import { BoundingBox, - CharRenderInfo, - Engine, Entity, - Font, - FontStyle, - ITextRenderer, - OverflowMode, + Material, + RenderContext, RenderQueueFlags, RendererUpdateFlags, ShaderData, ShaderDataGroup, - ShaderProperty, - SubFont, - TextHorizontalAlignment, - TextUtils, - TextVerticalAlignment, - Texture2D, - Vector3, - assignmentClone, + TextChunk, + TextRenderable, + TextRenderableFlags, ignoreClone } from "@galacean/engine"; +import type { ISpriteLayout, ITextRenderable } from "@galacean/engine"; import { CanvasRenderMode } from "../../enums/CanvasRenderMode"; import { RootCanvasModifyFlags } from "../UICanvas"; -import { UIRenderer, UIRendererUpdateFlags } from "../UIRenderer"; +import { UIRenderer } from "../UIRenderer"; import { UITransform, UITransformModifyFlags } from "../UITransform"; +import { UISpriteLayout } from "./UISpriteLayout"; /** * UI component used to render text. */ -export class Text extends UIRenderer implements ITextRenderer { - private static _textTextureProperty = ShaderProperty.getByName("renderElement_TextTexture"); - private static _worldPositions = [new Vector3(), new Vector3(), new Vector3(), new Vector3()]; - private static _charRenderInfos: CharRenderInfo[] = []; - - @ignoreClone - private _textChunks = Array(); - @ignoreClone - private _subFont: SubFont = null; - @assignmentClone - private _text: string = ""; - @ignoreClone - private _localBounds: BoundingBox = new BoundingBox(); - @assignmentClone - private _font: Font = null; - @assignmentClone - private _fontSize: number = 24; - @assignmentClone - private _fontStyle: FontStyle = FontStyle.None; - @assignmentClone - private _lineSpacing: number = 0; - @assignmentClone - private _characterSpacing: number = 0; - @assignmentClone - private _horizontalAlignment: TextHorizontalAlignment = TextHorizontalAlignment.Center; - @assignmentClone - private _verticalAlignment: TextVerticalAlignment = TextVerticalAlignment.Center; - @assignmentClone - private _enableWrapping: boolean = false; - @assignmentClone - private _overflowMode: OverflowMode = OverflowMode.Overflow; - - /** - * Rendering string for the Text. - */ - get text(): string { - return this._text; - } - - set text(value: string) { - value = value || ""; - if (this._text !== value) { - this._text = value; - this._setDirtyFlagTrue(DirtyFlag.Position); - } - } - - /** - * The font of the Text. - */ - get font(): Font { - return this._font; - } - - set font(value: Font) { - const lastFont = this._font; - if (lastFont !== value) { - lastFont && this._addResourceReferCount(lastFont, -1); - value && this._addResourceReferCount(value, 1); - this._font = value; - this._setDirtyFlagTrue(DirtyFlag.Font); - } - } - - /** - * The font size of the Text. - */ - get fontSize(): number { - return this._fontSize; - } - - set fontSize(value: number) { - if (this._fontSize !== value) { - this._fontSize = value; - this._setDirtyFlagTrue(DirtyFlag.Font); - } - } - - /** - * The style of the font. - */ - get fontStyle(): FontStyle { - return this._fontStyle; - } - - set fontStyle(value: FontStyle) { - if (this.fontStyle !== value) { - this._fontStyle = value; - this._setDirtyFlagTrue(DirtyFlag.Font); - } - } - - /** - * The space between two lines, in em (ratio of fontSize). - */ - get lineSpacing(): number { - return this._lineSpacing; - } - - set lineSpacing(value: number) { - if (this._lineSpacing !== value) { - this._lineSpacing = value; - this._setDirtyFlagTrue(DirtyFlag.Position); - } - } - - /** - * The space between two characters, in em (ratio of fontSize). - */ - get characterSpacing(): number { - return this._characterSpacing; - } - - set characterSpacing(value: number) { - if (this._characterSpacing !== value) { - this._characterSpacing = value; - this._setDirtyFlagTrue(DirtyFlag.Position); - } +export class Text extends TextRenderable(UIRenderer) { + constructor(entity: Entity) { + super(entity); + this._initTextRenderable(); + this.raycastEnabled = false; } - /** - * The horizontal alignment. - */ - get horizontalAlignment(): TextHorizontalAlignment { - return this._horizontalAlignment; - } + // ===== Abstract implementations ===== - set horizontalAlignment(value: TextHorizontalAlignment) { - if (this._horizontalAlignment !== value) { - this._horizontalAlignment = value; - this._setDirtyFlagTrue(DirtyFlag.Position); - } + override _createLayout(): ISpriteLayout { + return new UISpriteLayout( + () => this._transformEntity.transform, + () => this._getRootCanvas() + ); } - /** - * The vertical alignment. - */ - get verticalAlignment(): TextVerticalAlignment { - return this._verticalAlignment; - } + override _submitText(context: RenderContext, material: Material): void { + const canvas = this._getRootCanvas(); + if (!canvas) return; - set verticalAlignment(value: TextVerticalAlignment) { - if (this._verticalAlignment !== value) { - this._verticalAlignment = value; - this._setDirtyFlagTrue(DirtyFlag.Position); + const engine = context.camera.engine; + const textSubRenderElementPool = engine._textSubRenderElementPool; + const renderElement = canvas._renderElement; + const textChunks = this._getTextChunks(); + const textureProperty = this._getTextTextureProperty(); + const isOverlay = canvas._realRenderMode === CanvasRenderMode.ScreenSpaceOverlay; + for (let i = 0, n = textChunks.length; i < n; ++i) { + const { subChunk, texture } = textChunks[i]; + const subRenderElement = textSubRenderElementPool.get(); + subRenderElement.set(this, material, subChunk.chunk.primitive, subChunk.subMesh, texture, subChunk); + // @ts-ignore + subRenderElement.shaderData ||= new ShaderData(ShaderDataGroup.RenderElement); + subRenderElement.shaderData.setTexture(textureProperty, texture); + if (isOverlay) { + subRenderElement.shaderPasses = material.shader.subShaders[0].passes; + subRenderElement.renderQueueFlags = RenderQueueFlags.All; + } + renderElement.addSubRenderElement(subRenderElement); } } - /** - * Whether wrap text to next line when exceeds the width of the container. - */ - get enableWrapping(): boolean { - return this._enableWrapping; - } + // ===== Override defaults ===== - set enableWrapping(value: boolean) { - if (this._enableWrapping !== value) { - this._enableWrapping = value; - this._setDirtyFlagTrue(DirtyFlag.Position); - } + override _getTextAlpha(): number { + return this._getGlobalAlpha(); } - /** - * The overflow mode. - */ - get overflowMode(): OverflowMode { - return this._overflowMode; + override _isTextHostInvisible(): boolean { + return !this._getRootCanvas(); } - set overflowMode(value: OverflowMode) { - if (this._overflowMode !== value) { - this._overflowMode = value; - this._setDirtyFlagTrue(DirtyFlag.Position); - } - } + // ===== Text-specific ===== /** - * The mask layer the sprite renderer belongs to. + * The mask layer the text belongs to. */ get maskLayer(): number { return this._maskLayer; @@ -217,101 +87,21 @@ export class Text extends UIRenderer implements ITextRenderer { } /** - * The bounding volume of the TextRenderer. + * @internal */ - override get bounds(): BoundingBox { - if (this._isTextNoVisible()) { - if (this._isContainDirtyFlag(RendererUpdateFlags.WorldVolume)) { - const localBounds = this._localBounds; - localBounds.min.set(0, 0, 0); - localBounds.max.set(0, 0, 0); - this._updateBounds(this._bounds); - this._setDirtyFlagFalse(RendererUpdateFlags.WorldVolume); - } - return this._bounds; + _onRootCanvasModify(flag: RootCanvasModifyFlags): void { + if (flag === RootCanvasModifyFlags.ReferenceResolutionPerUnit) { + this._setDirtyFlagTrue(TextRenderableFlags.LocalPositionBounds); } - this._isContainDirtyFlag(DirtyFlag.SubFont) && this._resetSubFont(); - this._isContainDirtyFlag(DirtyFlag.LocalPositionBounds) && this._updateLocalData(); - this._isContainDirtyFlag(DirtyFlag.WorldPosition) && this._updatePosition(); - this._isContainDirtyFlag(RendererUpdateFlags.WorldVolume) && this._updateBounds(this._bounds); - this._setDirtyFlagFalse(DirtyFlag.Font); - - return this._bounds; - } - - constructor(entity: Entity) { - super(entity); - const { engine } = this; - // @ts-ignore - this.font = engine._textDefaultFont; - this.raycastEnabled = false; - // @ts-ignore - this.setMaterial(engine._basicResources.textDefaultMaterial); } /** * @internal */ - protected override _onDestroy(): void { - if (this._font) { - this._addResourceReferCount(this._font, -1); - this._font = null; - } - - super._onDestroy(); - - this._freeTextChunks(); - this._textChunks = null; - - this._subFont && (this._subFont = null); - } - // @ts-ignore override _cloneTo(target: Text): void { // @ts-ignore super._cloneTo(target); - target.font = this._font; - target._subFont = this._subFont; - } - - /** - * @internal - */ - _isContainDirtyFlag(type: number): boolean { - return (this._dirtyUpdateFlag & type) != 0; - } - - /** - * @internal - */ - _setDirtyFlagTrue(type: number): void { - this._dirtyUpdateFlag |= type; - } - - /** - * @internal - */ - _setDirtyFlagFalse(type: number): void { - this._dirtyUpdateFlag &= ~type; - } - - /** - * @internal - */ - _getSubFont(): SubFont { - if (!this._subFont) { - this._resetSubFont(); - } - return this._subFont; - } - - /** - * @internal - */ - _onRootCanvasModify(flag: RootCanvasModifyFlags): void { - if (flag === RootCanvasModifyFlags.ReferenceResolutionPerUnit) { - this._setDirtyFlagTrue(DirtyFlag.LocalPositionBounds); - } } protected override _updateBounds(worldBounds: BoundingBox): void { @@ -323,373 +113,11 @@ export class Text extends UIRenderer implements ITextRenderer { BoundingBox.transform(worldBounds, this._transformEntity.transform.worldMatrix, worldBounds); } - protected override _render(context): void { - if (this._isTextNoVisible()) { - return; - } - - if (this._isContainDirtyFlag(DirtyFlag.SubFont)) { - this._resetSubFont(); - this._setDirtyFlagFalse(DirtyFlag.SubFont); - } - - const canvas = this._getRootCanvas(); - if (this._isContainDirtyFlag(DirtyFlag.LocalPositionBounds)) { - this._updateLocalData(); - this._setDirtyFlagTrue(DirtyFlag.LocalPositionBounds); - } - - if (this._isContainDirtyFlag(DirtyFlag.WorldPosition)) { - this._updatePosition(); - this._setDirtyFlagFalse(DirtyFlag.WorldPosition); - } - - if (this._isContainDirtyFlag(UIRendererUpdateFlags.Color)) { - this._updateColor(); - this._setDirtyFlagFalse(UIRendererUpdateFlags.Color); - } - - const engine = context.camera.engine; - const textSubRenderElementPool = engine._textSubRenderElementPool; - const material = this.getMaterial(); - const renderElement = canvas._renderElement; - const textChunks = this._textChunks; - const isOverlay = canvas._realRenderMode === CanvasRenderMode.ScreenSpaceOverlay; - for (let i = 0, n = textChunks.length; i < n; ++i) { - const { subChunk, texture } = textChunks[i]; - const subRenderElement = textSubRenderElementPool.get(); - subRenderElement.set(this, material, subChunk.chunk.primitive, subChunk.subMesh, texture, subChunk); - // @ts-ignore - subRenderElement.shaderData ||= new ShaderData(ShaderDataGroup.RenderElement); - subRenderElement.shaderData.setTexture(Text._textTextureProperty, texture); - if (isOverlay) { - subRenderElement.shaderPasses = material.shader.subShaders[0].passes; - subRenderElement.renderQueueFlags = RenderQueueFlags.All; - } - renderElement.addSubRenderElement(subRenderElement); - } - } - - private _resetSubFont(): void { - const font = this._font; - // @ts-ignore - this._subFont = font._getSubFont(this.fontSize, this.fontStyle); - this._subFont.nativeFontString = TextUtils.getNativeFontString(font.name, this.fontSize, this.fontStyle); - } - - private _updatePosition(): void { - const e = this._transformEntity.transform.worldMatrix.elements; - - // prettier-ignore - const e0 = e[0], e1 = e[1], e2 = e[2], - e4 = e[4], e5 = e[5], e6 = e[6], - e12 = e[12], e13 = e[13], e14 = e[14]; - - const up = UIRenderer._tempVec31.set(e4, e5, e6); - const right = UIRenderer._tempVec30.set(e0, e1, e2); - - const worldPositions = Text._worldPositions; - const [worldPosition0, worldPosition1, worldPosition2, worldPosition3] = worldPositions; - const textChunks = this._textChunks; - for (let i = 0, n = textChunks.length; i < n; ++i) { - const { subChunk, charRenderInfos } = textChunks[i]; - for (let j = 0, m = charRenderInfos.length; j < m; ++j) { - const charRenderInfo = charRenderInfos[j]; - const { localPositions } = charRenderInfo; - const { x: topLeftX, y: topLeftY } = localPositions; - - // Top-Left - worldPosition0.set( - topLeftX * e0 + topLeftY * e4 + e12, - topLeftX * e1 + topLeftY * e5 + e13, - topLeftX * e2 + topLeftY * e6 + e14 - ); - - // Right offset - Vector3.scale(right, localPositions.z - topLeftX, worldPosition1); - // Top-Right - Vector3.add(worldPosition0, worldPosition1, worldPosition1); - // Up offset - Vector3.scale(up, localPositions.w - topLeftY, worldPosition2); - // Bottom-Left - Vector3.add(worldPosition0, worldPosition2, worldPosition3); - // Bottom-Right - Vector3.add(worldPosition1, worldPosition2, worldPosition2); - - const vertices = subChunk.chunk.vertices; - for (let k = 0, o = subChunk.vertexArea.start + charRenderInfo.indexInChunk * 36; k < 4; ++k, o += 9) { - worldPositions[k].copyToArray(vertices, o); - } - } - } - } - - private _updateColor(): void { - const { r, g, b, a } = this._color; - const finalAlpha = a * this._getGlobalAlpha(); - const textChunks = this._textChunks; - for (let i = 0, n = textChunks.length; i < n; ++i) { - const subChunk = textChunks[i].subChunk; - const vertexArea = subChunk.vertexArea; - const vertexCount = vertexArea.size / 9; - const vertices = subChunk.chunk.vertices; - for (let j = 0, o = vertexArea.start + 5; j < vertexCount; ++j, o += 9) { - vertices[o] = r; - vertices[o + 1] = g; - vertices[o + 2] = b; - vertices[o + 3] = finalAlpha; - } - } - } - - private _updateLocalData(): void { - // @ts-ignore - const pixelsPerResolution = Engine._pixelsPerUnit / this._getRootCanvas().referenceResolutionPerUnit; - const { min, max } = this._localBounds; - const charRenderInfos = Text._charRenderInfos; - const charFont = this._getSubFont(); - const { size, pivot } = this._transformEntity.transform; - let rendererWidth = size.x; - let rendererHeight = size.y; - const offsetWidth = rendererWidth * (0.5 - pivot.x); - const offsetHeight = rendererHeight * (0.5 - pivot.y); - const characterSpacing = this._characterSpacing * this._fontSize; - const textMetrics = this.enableWrapping - ? TextUtils.measureTextWithWrap( - this, - rendererWidth * pixelsPerResolution, - rendererHeight * pixelsPerResolution, - this._lineSpacing * this._fontSize, - characterSpacing - ) - : TextUtils.measureTextWithoutWrap( - this, - rendererHeight * pixelsPerResolution, - this._lineSpacing * this._fontSize, - characterSpacing - ); - const { height, lines, lineWidths, lineHeight, lineMaxSizes } = textMetrics; - // @ts-ignore - const charRenderInfoPool = this.engine._charRenderInfoPool; - const linesLen = lines.length; - let renderElementCount = 0; - - if (linesLen > 0) { - const { horizontalAlignment } = this; - const pixelsPerUnitReciprocal = 1.0 / pixelsPerResolution; - rendererWidth *= pixelsPerResolution; - rendererHeight *= pixelsPerResolution; - const halfRendererWidth = rendererWidth * 0.5; - const halfLineHeight = lineHeight * 0.5; - - let startY = 0; - const topDiff = lineHeight * 0.5 - lineMaxSizes[0].ascent; - const bottomDiff = lineHeight * 0.5 - lineMaxSizes[linesLen - 1].descent - 1; - switch (this.verticalAlignment) { - case TextVerticalAlignment.Top: - startY = rendererHeight * 0.5 - halfLineHeight + topDiff; - break; - case TextVerticalAlignment.Center: - startY = height * 0.5 - halfLineHeight - (bottomDiff - topDiff) * 0.5; - break; - case TextVerticalAlignment.Bottom: - startY = height - rendererHeight * 0.5 - halfLineHeight - bottomDiff; - break; - } - - let firstLine = -1; - let minX = Number.MAX_SAFE_INTEGER; - let minY = Number.MAX_SAFE_INTEGER; - let maxX = Number.MIN_SAFE_INTEGER; - let maxY = Number.MIN_SAFE_INTEGER; - for (let i = 0; i < linesLen; ++i) { - const lineWidth = lineWidths[i]; - if (lineWidth > 0) { - const line = lines[i]; - let startX = 0; - let firstRow = -1; - if (firstLine < 0) { - firstLine = i; - } - switch (horizontalAlignment) { - case TextHorizontalAlignment.Left: - startX = -halfRendererWidth; - break; - case TextHorizontalAlignment.Center: - startX = -lineWidth * 0.5; - break; - case TextHorizontalAlignment.Right: - startX = halfRendererWidth - lineWidth; - break; - } - for (let j = 0, n = line.length; j < n; ++j) { - const char = line[j]; - const charInfo = charFont._getCharInfo(char); - if (charInfo.h > 0) { - firstRow < 0 && (firstRow = j); - const charRenderInfo = (charRenderInfos[renderElementCount++] = charRenderInfoPool.get()); - const { localPositions } = charRenderInfo; - charRenderInfo.texture = charFont._getTextureByIndex(charInfo.index); - charRenderInfo.uvs = charInfo.uvs; - const { w, ascent, descent } = charInfo; - const left = (startX + offsetWidth) * pixelsPerUnitReciprocal; - const right = (startX + w + offsetWidth) * pixelsPerUnitReciprocal; - const top = (startY + ascent + offsetHeight) * pixelsPerUnitReciprocal; - const bottom = (startY - descent + offsetHeight) * pixelsPerUnitReciprocal; - localPositions.set(left, top, right, bottom); - i === firstLine && (maxY = Math.max(maxY, top)); - minY = Math.min(minY, bottom); - j === firstRow && (minX = Math.min(minX, left)); - maxX = Math.max(maxX, right); - } - startX += charInfo.xAdvance + characterSpacing; - } - } - startY -= lineHeight; - } - if (firstLine < 0) { - min.set(0, 0, 0); - max.set(0, 0, 0); - } else { - min.set(minX, minY, 0); - max.set(maxX, maxY, 0); - } - } else { - min.set(0, 0, 0); - max.set(0, 0, 0); - } - - charFont._getLastIndex() > 0 && - charRenderInfos.sort((a, b) => { - return a.texture.instanceId - b.texture.instanceId; - }); - - this._freeTextChunks(); - - if (renderElementCount === 0) { - return; - } - - const textChunks = this._textChunks; - let curTextChunk = new TextChunk(); - textChunks.push(curTextChunk); - - const chunkMaxVertexCount = this._getChunkManager().maxVertexCount; - const curCharRenderInfo = charRenderInfos[0]; - let curTexture = curCharRenderInfo.texture; - curTextChunk.texture = curTexture; - let curCharInfos = curTextChunk.charRenderInfos; - curCharInfos.push(curCharRenderInfo); - - for (let i = 1; i < renderElementCount; ++i) { - const charRenderInfo = charRenderInfos[i]; - const texture = charRenderInfo.texture; - if (curTexture !== texture || curCharInfos.length * 4 + 4 > chunkMaxVertexCount) { - this._buildChunk(curTextChunk, curCharInfos.length); - - curTextChunk = new TextChunk(); - textChunks.push(curTextChunk); - curTexture = texture; - curTextChunk.texture = texture; - curCharInfos = curTextChunk.charRenderInfos; - } - curCharInfos.push(charRenderInfo); - } - const charLength = curCharInfos.length; - if (charLength > 0) { - this._buildChunk(curTextChunk, charLength); - } - charRenderInfos.length = 0; - } - @ignoreClone protected override _onTransformChanged(type: number): void { if (type & UITransformModifyFlags.Size || type & UITransformModifyFlags.Pivot) { - this._dirtyUpdateFlag |= DirtyFlag.LocalPositionBounds; + this._setDirtyFlagTrue(TextRenderableFlags.LocalPositionBounds); } super._onTransformChanged(type); - this._setDirtyFlagTrue(DirtyFlag.WorldPosition); - } - - private _isTextNoVisible(): boolean { - const size = (this._transformEntity.transform).size; - return ( - !this._font || - this._text === "" || - this._fontSize === 0 || - (this.enableWrapping && size.x <= 0) || - (this.overflowMode === OverflowMode.Truncate && size.y <= 0) || - !this._getRootCanvas() - ); - } - - private _buildChunk(textChunk: TextChunk, count: number) { - const { r, g, b, a } = this.color; - const finalAlpha = a * this._getGlobalAlpha(); - const tempIndices = CharRenderInfo.triangles; - const tempIndicesLength = tempIndices.length; - const subChunk = (textChunk.subChunk = this._getChunkManager().allocateSubChunk(count * 4)); - const vertices = subChunk.chunk.vertices; - const indices = (subChunk.indices = []); - const charRenderInfos = textChunk.charRenderInfos; - for (let i = 0, ii = 0, io = 0, vo = subChunk.vertexArea.start + 3; i < count; ++i, io += 4) { - const charRenderInfo = charRenderInfos[i]; - charRenderInfo.indexInChunk = i; - - // Set indices - for (let j = 0; j < tempIndicesLength; ++j) { - indices[ii++] = tempIndices[j] + io; - } - - // Set uv and color for vertices - for (let j = 0; j < 4; ++j, vo += 9) { - const uv = charRenderInfo.uvs[j]; - uv.copyToArray(vertices, vo); - vertices[vo + 2] = r; - vertices[vo + 3] = g; - vertices[vo + 4] = b; - vertices[vo + 5] = finalAlpha; - } - } - - return subChunk; } - - private _freeTextChunks(): void { - const textChunks = this._textChunks; - // @ts-ignore - const charRenderInfoPool = this.engine._charRenderInfoPool; - const manager = this._getChunkManager(); - for (let i = 0, n = textChunks.length; i < n; ++i) { - const textChunk = textChunks[i]; - const { charRenderInfos } = textChunk; - for (let j = 0, m = charRenderInfos.length; j < m; ++j) { - charRenderInfoPool.return(charRenderInfos[j]); - } - charRenderInfos.length = 0; - manager.freeSubChunk(textChunk.subChunk); - textChunk.subChunk = null; - textChunk.texture = null; - } - textChunks.length = 0; - } -} - -class TextChunk { - charRenderInfos = new Array(); - texture: Texture2D; - subChunk; -} - -/** - * @remarks Extends `UIRendererUpdateFlags`. - */ -enum DirtyFlag { - SubFont = 0x4, - LocalPositionBounds = 0x8, - WorldPosition = 0x10, - - // LocalPositionBounds | WorldPosition | WorldVolume - Position = 0x19, - Font = SubFont | Position } diff --git a/packages/ui/src/component/advanced/UISpriteLayout.ts b/packages/ui/src/component/advanced/UISpriteLayout.ts new file mode 100644 index 0000000000..96521292fb --- /dev/null +++ b/packages/ui/src/component/advanced/UISpriteLayout.ts @@ -0,0 +1,56 @@ +import { Vector2 } from "@galacean/engine"; +import type { ISpriteLayout } from "@galacean/engine"; +import { UICanvas } from "../UICanvas"; +import { UITransform } from "../UITransform"; + +/** + * Layout for UI-space sprite renderers (Image, UI Mask). + * Reads size/pivot from UITransform and referenceResolutionPerUnit from UICanvas. + */ +export class UISpriteLayout implements ISpriteLayout { + private _transformGetter: () => UITransform; + private _canvasGetter: () => UICanvas | null; + + constructor(transformGetter: () => UITransform, canvasGetter: () => UICanvas | null) { + this._transformGetter = transformGetter; + this._canvasGetter = canvasGetter; + } + + get width(): number { + return this._transformGetter().size.x; + } + + get height(): number { + return this._transformGetter().size.y; + } + + get pivot(): Vector2 { + return this._transformGetter().pivot; + } + + get flipX(): boolean { + return false; + } + + get flipY(): boolean { + return false; + } + + get referenceResolutionPerUnit(): number | undefined { + return this._canvasGetter()?.referenceResolutionPerUnit; + } + + onSpriteInstanceChanged(): void { + // UI sprites don't track automatic size from sprite — size comes from UITransform. + } + + onSpriteSizeChanged(): number { + // Sprite.size changes don't affect UI renderers (they use UITransform.size). + return 0; + } + + onSpritePivotChanged(): number { + // Sprite.pivot changes don't affect UI renderers (they use UITransform.pivot). + return 0; + } +} diff --git a/tests/src/core/SpriteRenderer.test.ts b/tests/src/core/SpriteRenderer.test.ts index bbb4d98266..a3252efa62 100644 --- a/tests/src/core/SpriteRenderer.test.ts +++ b/tests/src/core/SpriteRenderer.test.ts @@ -1584,17 +1584,15 @@ describe("SpriteRenderer", async () => { * @remarks Extends `RendererUpdateFlags`. */ enum SpriteRendererUpdateFlags { - /** UV. */ - UV = 0x2, /** Color. */ - Color = 0x4, - /** Automatic Size. */ - AutomaticSize = 0x8, + Color = 0x2, + /** UV. */ + UV = 0x4, /** WorldVolume and UV. */ - WorldVolumeAndUV = 0x3, + WorldVolumeAndUV = 0x5, /** WorldVolume, UV and Color. */ WorldVolumeUVAndColor = 0x7, /** All. */ - All = 0xf + All = 0x7 } From 3a3f9ed8cd10b365eeaf644a26b65ee182220136 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Thu, 19 Mar 2026 15:50:04 +0800 Subject: [PATCH 2/9] refactor: sprite renderable and text renderable --- packages/core/src/2d/sprite/SpriteRenderable.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/core/src/2d/sprite/SpriteRenderable.ts b/packages/core/src/2d/sprite/SpriteRenderable.ts index 42afd18662..933ea56be5 100644 --- a/packages/core/src/2d/sprite/SpriteRenderable.ts +++ b/packages/core/src/2d/sprite/SpriteRenderable.ts @@ -205,11 +205,7 @@ export function SpriteRenderable( * @internal */ _initSpriteRenderable(textureProperty: ShaderProperty): void { - this._dataBinding = new SpriteDataBinding( - this as any, - textureProperty, - this._onSpriteChanged.bind(this) - ); + this._dataBinding = new SpriteDataBinding(this as any, textureProperty, this._onSpriteChanged.bind(this)); this._layout = this._createLayout(); this.drawMode = SpriteDrawMode.Simple; this._dirtyUpdateFlag |= SpriteRenderableFlags.Color; From d5d44d9d77e4dcd436bd9bda197edb10ee395d86 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Thu, 19 Mar 2026 17:35:47 +0800 Subject: [PATCH 3/9] feat: update code --- .../core/src/2d/assembler/ISpriteAssembler.ts | 26 +-- .../core/src/2d/assembler/ISpriteRenderer.ts | 17 -- .../src/2d/assembler/SimpleSpriteAssembler.ts | 46 +++--- .../src/2d/assembler/SlicedSpriteAssembler.ts | 54 +++--- .../src/2d/assembler/TiledSpriteAssembler.ts | 79 +++++---- packages/core/src/2d/index.ts | 1 - packages/core/src/2d/sprite/ISpriteLayout.ts | 19 +-- packages/core/src/2d/sprite/SpriteMask.ts | 107 +++++------- ...priteDataBinding.ts => SpritePrimitive.ts} | 35 ++-- .../core/src/2d/sprite/SpriteRenderable.ts | 154 +++++++++++------- packages/core/src/2d/sprite/SpriteRenderer.ts | 107 +++++++++--- .../core/src/2d/sprite/WorldSpriteLayout.ts | 120 -------------- packages/core/src/2d/sprite/index.ts | 5 +- packages/core/src/2d/text/TextRenderable.ts | 59 ++++--- packages/core/src/2d/text/TextRenderer.ts | 27 +-- packages/core/src/2d/text/WorldTextLayout.ts | 54 ------ packages/core/src/2d/text/index.ts | 1 - packages/ui/src/component/advanced/Image.ts | 25 ++- packages/ui/src/component/advanced/Text.ts | 27 ++- .../src/component/advanced/UISpriteLayout.ts | 56 ------- 20 files changed, 451 insertions(+), 568 deletions(-) delete mode 100644 packages/core/src/2d/assembler/ISpriteRenderer.ts rename packages/core/src/2d/sprite/{SpriteDataBinding.ts => SpritePrimitive.ts} (76%) delete mode 100644 packages/core/src/2d/sprite/WorldSpriteLayout.ts delete mode 100644 packages/core/src/2d/text/WorldTextLayout.ts delete mode 100644 packages/ui/src/component/advanced/UISpriteLayout.ts diff --git a/packages/core/src/2d/assembler/ISpriteAssembler.ts b/packages/core/src/2d/assembler/ISpriteAssembler.ts index 876c6f87f7..38ecfb93c3 100644 --- a/packages/core/src/2d/assembler/ISpriteAssembler.ts +++ b/packages/core/src/2d/assembler/ISpriteAssembler.ts @@ -1,21 +1,23 @@ -import { Matrix, Vector2 } from "@galacean/engine-math"; -import { ISpriteRenderer } from "./ISpriteRenderer"; +import { BoundingBox, Color, Matrix } from "@galacean/engine-math"; +import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManager"; +import { SpriteTileMode } from "../enums/SpriteTileMode"; +import { ISpriteLayout } from "../sprite/ISpriteLayout"; +import { SpritePrimitive } from "../sprite/SpritePrimitive"; /** * Interface for sprite assembler. */ export interface ISpriteAssembler { - resetData(renderer: ISpriteRenderer, vertexCount?: number): void; + resetData(primitive: SpritePrimitive, chunkManager: PrimitiveChunkManager, vertexCount?: number): void; updatePositions( - renderer: ISpriteRenderer, + primitive: SpritePrimitive, + chunkManager: PrimitiveChunkManager, + layout: ISpriteLayout, worldMatrix: Matrix, - width: number, - height: number, - pivot: Vector2, - flipX: boolean, - flipY: boolean, - referenceResolutionPerUnit?: number + outBounds: BoundingBox, + tileMode?: SpriteTileMode, + tiledAdaptiveThreshold?: number ): void; - updateUVs(renderer: ISpriteRenderer): void; - updateColor(renderer: ISpriteRenderer, alpha: number): void; + updateUVs(primitive: SpritePrimitive): void; + updateColor(primitive: SpritePrimitive, color: Color, alpha: number): void; } diff --git a/packages/core/src/2d/assembler/ISpriteRenderer.ts b/packages/core/src/2d/assembler/ISpriteRenderer.ts deleted file mode 100644 index a72f4e9436..0000000000 --- a/packages/core/src/2d/assembler/ISpriteRenderer.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Color } from "@galacean/engine-math"; -import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManager"; -import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; -import { SpriteTileMode } from "../enums/SpriteTileMode"; -import { Sprite } from "../sprite"; - -/** - * Interface for sprite renderer. - */ -export interface ISpriteRenderer { - sprite: Sprite; - color?: Color; - tileMode?: SpriteTileMode; - tiledAdaptiveThreshold?: number; - _subChunk: SubPrimitiveChunk; - _getChunkManager(): PrimitiveChunkManager; -} diff --git a/packages/core/src/2d/assembler/SimpleSpriteAssembler.ts b/packages/core/src/2d/assembler/SimpleSpriteAssembler.ts index 563f812106..b6bc07e60a 100644 --- a/packages/core/src/2d/assembler/SimpleSpriteAssembler.ts +++ b/packages/core/src/2d/assembler/SimpleSpriteAssembler.ts @@ -1,7 +1,9 @@ -import { BoundingBox, Matrix, Vector2 } from "@galacean/engine-math"; +import { BoundingBox, Color, Matrix } from "@galacean/engine-math"; import { StaticInterfaceImplement } from "../../base/StaticInterfaceImplement"; +import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManager"; +import { ISpriteLayout } from "../sprite/ISpriteLayout"; +import { SpritePrimitive } from "../sprite/SpritePrimitive"; import { ISpriteAssembler } from "./ISpriteAssembler"; -import { ISpriteRenderer } from "./ISpriteRenderer"; /** * Assemble vertex data for the sprite renderer in simple mode. @@ -11,25 +13,23 @@ export class SimpleSpriteAssembler { private static _rectangleTriangles = [0, 1, 2, 2, 1, 3]; private static _matrix = new Matrix(); - static resetData(renderer: ISpriteRenderer): void { - const manager = renderer._getChunkManager(); - const lastSubChunk = renderer._subChunk; - lastSubChunk && manager.freeSubChunk(lastSubChunk); - const subChunk = manager.allocateSubChunk(4); + static resetData(primitive: SpritePrimitive, chunkManager: PrimitiveChunkManager): void { + const lastSubChunk = primitive.subChunk; + lastSubChunk && chunkManager.freeSubChunk(lastSubChunk); + const subChunk = chunkManager.allocateSubChunk(4); subChunk.indices = SimpleSpriteAssembler._rectangleTriangles; - renderer._subChunk = subChunk; + primitive.subChunk = subChunk; } static updatePositions( - renderer: ISpriteRenderer, + primitive: SpritePrimitive, + chunkManager: PrimitiveChunkManager, + layout: ISpriteLayout, worldMatrix: Matrix, - width: number, - height: number, - pivot: Vector2, - flipX: boolean, - flipY: boolean + outBounds: BoundingBox ): void { - const { sprite } = renderer; + const { sprite } = primitive; + const { width, height, pivot, flipX, flipY } = layout; const { x: pivotX, y: pivotY } = pivot; // Position to World const modelMatrix = SimpleSpriteAssembler._matrix; @@ -52,7 +52,7 @@ export class SimpleSpriteAssembler { // --------------- // Update positions const spritePositions = sprite._getPositions(); - const subChunk = renderer._subChunk; + const subChunk = primitive.subChunk; const vertices = subChunk.chunk.vertices; for (let i = 0, o = subChunk.vertexArea.start; i < 4; ++i, o += 9) { const { x, y } = spritePositions[i]; @@ -62,14 +62,14 @@ export class SimpleSpriteAssembler { } // @ts-ignore - BoundingBox.transform(sprite._getBounds(), modelMatrix, renderer._bounds); + BoundingBox.transform(sprite._getBounds(), modelMatrix, outBounds); } - static updateUVs(renderer: ISpriteRenderer): void { - const spriteUVs = renderer.sprite._getUVs(); + static updateUVs(primitive: SpritePrimitive): void { + const spriteUVs = primitive.sprite._getUVs(); const { x: left, y: bottom } = spriteUVs[0]; const { x: right, y: top } = spriteUVs[3]; - const subChunk = renderer._subChunk; + const subChunk = primitive.subChunk; const vertices = subChunk.chunk.vertices; const offset = subChunk.vertexArea.start + 3; vertices[offset] = left; @@ -82,9 +82,9 @@ export class SimpleSpriteAssembler { vertices[offset + 28] = top; } - static updateColor(renderer: ISpriteRenderer, alpha: number): void { - const subChunk = renderer._subChunk; - const { r, g, b, a } = renderer.color; + static updateColor(primitive: SpritePrimitive, color: Color, alpha: number): void { + const subChunk = primitive.subChunk; + const { r, g, b, a } = color; const finalAlpha = a * alpha; const vertices = subChunk.chunk.vertices; for (let i = 0, o = subChunk.vertexArea.start + 5; i < 4; ++i, o += 9) { diff --git a/packages/core/src/2d/assembler/SlicedSpriteAssembler.ts b/packages/core/src/2d/assembler/SlicedSpriteAssembler.ts index 31d19d149c..32e4e70ec4 100644 --- a/packages/core/src/2d/assembler/SlicedSpriteAssembler.ts +++ b/packages/core/src/2d/assembler/SlicedSpriteAssembler.ts @@ -1,7 +1,9 @@ -import { Matrix, Vector2 } from "@galacean/engine-math"; +import { BoundingBox, Color, Matrix } from "@galacean/engine-math"; import { StaticInterfaceImplement } from "../../base/StaticInterfaceImplement"; +import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManager"; +import { ISpriteLayout } from "../sprite/ISpriteLayout"; +import { SpritePrimitive } from "../sprite/SpritePrimitive"; import { ISpriteAssembler } from "./ISpriteAssembler"; -import { ISpriteRenderer } from "./ISpriteRenderer"; /** * Assemble vertex data for the sprite renderer in sliced mode. @@ -16,26 +18,24 @@ export class SlicedSpriteAssembler { private static _row = new Array(4); private static _column = new Array(4); - static resetData(renderer: ISpriteRenderer): void { - const manager = renderer._getChunkManager(); - const lastSubChunk = renderer._subChunk; - lastSubChunk && manager.freeSubChunk(lastSubChunk); - const subChunk = manager.allocateSubChunk(16); + static resetData(primitive: SpritePrimitive, chunkManager: PrimitiveChunkManager): void { + const lastSubChunk = primitive.subChunk; + lastSubChunk && chunkManager.freeSubChunk(lastSubChunk); + const subChunk = chunkManager.allocateSubChunk(16); subChunk.indices = SlicedSpriteAssembler._rectangleTriangles; - renderer._subChunk = subChunk; + primitive.subChunk = subChunk; } static updatePositions( - renderer: ISpriteRenderer, + primitive: SpritePrimitive, + chunkManager: PrimitiveChunkManager, + layout: ISpriteLayout, worldMatrix: Matrix, - width: number, - height: number, - pivot: Vector2, - flipX: boolean, - flipY: boolean, - referenceResolutionPerUnit: number = 1 + outBounds: BoundingBox ): void { - const { sprite } = renderer; + const { sprite } = primitive; + const { width, height, pivot, flipX, flipY } = layout; + const referenceResolutionPerUnit = layout.referenceResolutionPerUnit ?? 1; const { border } = sprite; // Update local positions. const spritePositions = sprite._getPositions(); @@ -106,7 +106,7 @@ export class SlicedSpriteAssembler { // 0 - 4 - 8 - 12 // ------------------------ // Assemble position and uv. - const subChunk = renderer._subChunk; + const subChunk = primitive.subChunk; const vertices = subChunk.chunk.vertices; for (let i = 0, o = subChunk.vertexArea.start; i < 4; i++) { const rowValue = row[i]; @@ -118,17 +118,15 @@ export class SlicedSpriteAssembler { } } - // @ts-ignore - const bounds = renderer._bounds; - bounds.min.set(row[0], column[0], 0); - bounds.max.set(row[3], column[3], 0); - bounds.transform(modelMatrix); + outBounds.min.set(row[0], column[0], 0); + outBounds.max.set(row[3], column[3], 0); + outBounds.transform(modelMatrix); } - static updateUVs(renderer: ISpriteRenderer): void { - const subChunk = renderer._subChunk; + static updateUVs(primitive: SpritePrimitive): void { + const subChunk = primitive.subChunk; const vertices = subChunk.chunk.vertices; - const spriteUVs = renderer.sprite._getUVs(); + const spriteUVs = primitive.sprite._getUVs(); for (let i = 0, o = subChunk.vertexArea.start + 3; i < 4; i++) { const rowU = spriteUVs[i].x; for (let j = 0; j < 4; j++, o += 9) { @@ -138,9 +136,9 @@ export class SlicedSpriteAssembler { } } - static updateColor(renderer: ISpriteRenderer, alpha: number): void { - const subChunk = renderer._subChunk; - const { r, g, b, a } = renderer.color; + static updateColor(primitive: SpritePrimitive, color: Color, alpha: number): void { + const subChunk = primitive.subChunk; + const { r, g, b, a } = color; const finalAlpha = a * alpha; const vertices = subChunk.chunk.vertices; for (let i = 0, o = subChunk.vertexArea.start + 5; i < 16; ++i, o += 9) { diff --git a/packages/core/src/2d/assembler/TiledSpriteAssembler.ts b/packages/core/src/2d/assembler/TiledSpriteAssembler.ts index bc765c45f9..91a6da5481 100644 --- a/packages/core/src/2d/assembler/TiledSpriteAssembler.ts +++ b/packages/core/src/2d/assembler/TiledSpriteAssembler.ts @@ -1,10 +1,13 @@ -import { MathUtil, Matrix, Vector2 } from "@galacean/engine-math"; +import { BoundingBox, Color, MathUtil, Matrix } from "@galacean/engine-math"; import { Logger } from "../../base"; import { StaticInterfaceImplement } from "../../base/StaticInterfaceImplement"; +import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManager"; import { DisorderedArray } from "../../utils/DisorderedArray"; import { SpriteTileMode } from "../enums/SpriteTileMode"; +import { ISpriteLayout } from "../sprite/ISpriteLayout"; +import { Sprite } from "../sprite/Sprite"; +import { SpritePrimitive } from "../sprite/SpritePrimitive"; import { ISpriteAssembler } from "./ISpriteAssembler"; -import { ISpriteRenderer } from "./ISpriteRenderer"; /** * Assemble vertex data for the sprite renderer in tiled mode. @@ -17,36 +20,49 @@ export class TiledSpriteAssembler { private static _uvRow = new DisorderedArray(); private static _uvColumn = new DisorderedArray(); - static resetData(renderer: ISpriteRenderer, vertexCount: number): void { + static resetData(primitive: SpritePrimitive, chunkManager: PrimitiveChunkManager, vertexCount: number): void { if (vertexCount) { - const manager = renderer._getChunkManager(); - const lastSubChunk = renderer._subChunk; + const lastSubChunk = primitive.subChunk; const sizeChanged = lastSubChunk && lastSubChunk.vertexArea.size !== vertexCount * 9; - sizeChanged && manager.freeSubChunk(lastSubChunk); + sizeChanged && chunkManager.freeSubChunk(lastSubChunk); if (!lastSubChunk || sizeChanged) { - const newSubChunk = manager.allocateSubChunk(vertexCount); + const newSubChunk = chunkManager.allocateSubChunk(vertexCount); newSubChunk.indices = []; - renderer._subChunk = newSubChunk; + primitive.subChunk = newSubChunk; } } } static updatePositions( - renderer: ISpriteRenderer, + primitive: SpritePrimitive, + chunkManager: PrimitiveChunkManager, + layout: ISpriteLayout, worldMatrix: Matrix, - width: number, - height: number, - pivot: Vector2, - flipX: boolean, - flipY: boolean, - referenceResolutionPerUnit: number = 1 + outBounds: BoundingBox, + tileMode?: SpriteTileMode, + tiledAdaptiveThreshold?: number ): void { + const { width, height, pivot, flipX, flipY } = layout; + const referenceResolutionPerUnit = layout.referenceResolutionPerUnit ?? 1; // Calculate row and column const { _posRow: rPos, _posColumn: cPos, _uvRow: rUV, _uvColumn: cUV } = TiledSpriteAssembler; TiledSpriteAssembler.resetData( - renderer, - TiledSpriteAssembler._calculateDividing(renderer, width, height, rPos, cPos, rUV, cUV, referenceResolutionPerUnit) + primitive, + chunkManager, + TiledSpriteAssembler._calculateDividing( + primitive.sprite, + tileMode, + tiledAdaptiveThreshold, + chunkManager.maxVertexCount, + width, + height, + rPos, + cPos, + rUV, + cUV, + referenceResolutionPerUnit + ) ); // Update renderer's worldMatrix const { x: pivotX, y: pivotY } = pivot; @@ -71,7 +87,7 @@ export class TiledSpriteAssembler { const rowLength = rPos.length - 1; const columnLength = cPos.length - 1; - const subChunk = renderer._subChunk; + const subChunk = primitive.subChunk; const vertices = subChunk.chunk.vertices; const indices = subChunk.indices; let count = 0; @@ -118,18 +134,16 @@ export class TiledSpriteAssembler { } } - // @ts-ignore - const bounds = renderer._bounds; - bounds.min.set(rPos.get(0), cPos.get(0), 0); - bounds.max.set(rPos.get(rowLength), cPos.get(columnLength), 0); - bounds.transform(modelMatrix); + outBounds.min.set(rPos.get(0), cPos.get(0), 0); + outBounds.max.set(rPos.get(rowLength), cPos.get(columnLength), 0); + outBounds.transform(modelMatrix); } - static updateUVs(renderer: ISpriteRenderer): void { + static updateUVs(primitive: SpritePrimitive): void { const { _posRow: posRow, _posColumn: posColumn, _uvRow: uvRow, _uvColumn: uvColumn } = TiledSpriteAssembler; const rowLength = posRow.length - 1; const columnLength = posColumn.length - 1; - const subChunk = renderer._subChunk; + const subChunk = primitive.subChunk; const vertices = subChunk.chunk.vertices; for (let j = 0, o = subChunk.vertexArea.start + 3; j < columnLength; j++) { const doubleJ = 2 * j; @@ -159,9 +173,9 @@ export class TiledSpriteAssembler { } } - static updateColor(renderer: ISpriteRenderer, alpha: number): void { - const subChunk = renderer._subChunk; - const { r, g, b, a } = renderer.color; + static updateColor(primitive: SpritePrimitive, color: Color, alpha: number): void { + const subChunk = primitive.subChunk; + const { r, g, b, a } = color; const finalAlpha = a * alpha; const vertices = subChunk.chunk.vertices; const vertexArea = subChunk.vertexArea; @@ -174,7 +188,10 @@ export class TiledSpriteAssembler { } private static _calculateDividing( - renderer: ISpriteRenderer, + sprite: Sprite, + tileMode: SpriteTileMode, + threshold: number, + maxVertexCount: number, width: number, height: number, rPos: DisorderedArray, @@ -183,7 +200,6 @@ export class TiledSpriteAssembler { cUV: DisorderedArray, referenceResolutionPerUnit: number ): number { - const { sprite, tiledAdaptiveThreshold: threshold } = renderer; const { border } = sprite; const spritePositions = sprite._getPositions(); const { x: left, y: bottom } = spritePositions[0]; @@ -199,7 +215,7 @@ export class TiledSpriteAssembler { const fixedB = expectHeight * border.y; const fixedTB = fixedT + fixedB; const fixedCH = expectHeight - fixedTB; - const isAdaptive = renderer.tileMode === SpriteTileMode.Adaptive; + const isAdaptive = tileMode === SpriteTileMode.Adaptive; let rType: TiledType, rBlocksCount: number, rTiledCount: number; let cType: TiledType, cBlocksCount: number, cTiledCount: number; if (fixedLR >= width) { @@ -241,7 +257,6 @@ export class TiledSpriteAssembler { rPos.length = cPos.length = rUV.length = cUV.length = 0; const vertexCount = rBlocksCount * cBlocksCount * 4; - const maxVertexCount = renderer._getChunkManager().maxVertexCount; if (vertexCount > maxVertexCount) { rPos.add(width * left), rPos.add(width * right); cPos.add(height * bottom), cPos.add(height * top); diff --git a/packages/core/src/2d/index.ts b/packages/core/src/2d/index.ts index 47be64ccfc..65b033c745 100644 --- a/packages/core/src/2d/index.ts +++ b/packages/core/src/2d/index.ts @@ -1,5 +1,4 @@ export type { ISpriteAssembler } from "./assembler/ISpriteAssembler"; -export type { ISpriteRenderer } from "./assembler/ISpriteRenderer"; export { SimpleSpriteAssembler } from "./assembler/SimpleSpriteAssembler"; export { SlicedSpriteAssembler } from "./assembler/SlicedSpriteAssembler"; export { TiledSpriteAssembler } from "./assembler/TiledSpriteAssembler"; diff --git a/packages/core/src/2d/sprite/ISpriteLayout.ts b/packages/core/src/2d/sprite/ISpriteLayout.ts index f7dd474bbe..8a42016dd8 100644 --- a/packages/core/src/2d/sprite/ISpriteLayout.ts +++ b/packages/core/src/2d/sprite/ISpriteLayout.ts @@ -1,12 +1,7 @@ import { Vector2 } from "@galacean/engine-math"; -import { SpriteModifyFlags } from "../enums/SpriteModifyFlags"; /** - * Provides layout input (width, height, pivot, flip, referenceResolutionPerUnit) - * for sprite rendering. Different hosts use different layouts: - * - * - World-space (SpriteRenderer, SpriteMask): customWidth/automaticWidth, sprite.pivot, flipX/flipY - * - UI-space (Image, UI Mask): UITransform.size, UITransform.pivot, no flip + * Geometry input for sprite assemblers. */ export interface ISpriteLayout { readonly width: number; @@ -15,16 +10,4 @@ export interface ISpriteLayout { readonly flipX: boolean; readonly flipY: boolean; readonly referenceResolutionPerUnit: number | undefined; - - /** - * Called when sprite property changes. Returns additional dirty flags the host should set. - * Only called for types that the layout cares about (size, pivot). - */ - onSpriteSizeChanged(): number; - onSpritePivotChanged(): number; - - /** - * Called when the sprite instance is replaced. Layout should reset internal state (e.g. auto-size). - */ - onSpriteInstanceChanged(): void; } diff --git a/packages/core/src/2d/sprite/SpriteMask.ts b/packages/core/src/2d/sprite/SpriteMask.ts index 141f352d35..6189d39b8b 100644 --- a/packages/core/src/2d/sprite/SpriteMask.ts +++ b/packages/core/src/2d/sprite/SpriteMask.ts @@ -5,21 +5,20 @@ import { BatchUtils } from "../../RenderPipeline/BatchUtils"; import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManager"; import { RenderContext } from "../../RenderPipeline/RenderContext"; import { RenderElement } from "../../RenderPipeline/RenderElement"; -import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; import { SubRenderElement } from "../../RenderPipeline/SubRenderElement"; import { Renderer, RendererUpdateFlags } from "../../Renderer"; import { assignmentClone, ignoreClone } from "../../clone/CloneManager"; import { SpriteMaskLayer } from "../../enums/SpriteMaskLayer"; import { ShaderProperty } from "../../shader/ShaderProperty"; -import { ISpriteRenderer } from "../assembler/ISpriteRenderer"; import { SimpleSpriteAssembler } from "../assembler/SimpleSpriteAssembler"; import { SpriteModifyFlags } from "../enums/SpriteModifyFlags"; import { Sprite } from "./Sprite"; +import { SpritePrimitive } from "./SpritePrimitive"; /** * A component for masking Sprites. */ -export class SpriteMask extends Renderer implements ISpriteRenderer { +export class SpriteMask extends Renderer { /** @internal */ static _textureProperty: ShaderProperty = ShaderProperty.getByName("renderer_MaskTexture"); /** @internal */ @@ -34,14 +33,11 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { /** @internal */ @ignoreClone - _subChunk: SubPrimitiveChunk; + _spriteData: SpritePrimitive; /** @internal */ @ignoreClone _maskIndex: number = -1; - @ignoreClone - private _sprite: Sprite = null; - @ignoreClone private _automaticWidth: number = 0; @ignoreClone @@ -136,26 +132,12 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { * The Sprite to render. */ get sprite(): Sprite { - return this._sprite; + return this._spriteData.sprite; } set sprite(value: Sprite | null) { - const lastSprite = this._sprite; - if (lastSprite !== value) { - if (lastSprite) { - this._addResourceReferCount(lastSprite, -1); - lastSprite._updateFlagManager.removeListener(this._onSpriteChange); - } - this._dirtyUpdateFlag |= SpriteMaskUpdateFlags.All; - if (value) { - this._addResourceReferCount(value, 1); - value._updateFlagManager.addListener(this._onSpriteChange); - this.shaderData.setTexture(SpriteMask._textureProperty, value.texture); - } else { - this.shaderData.setTexture(SpriteMask._textureProperty, null); - } - this._sprite = value; - } + this._spriteData.sprite = value; + this._dirtyUpdateFlag |= SpriteMaskUpdateFlags.All; } /** @@ -177,12 +159,12 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { */ constructor(entity: Entity) { super(entity); - SimpleSpriteAssembler.resetData(this); + this._spriteData = new SpritePrimitive(this as any, SpriteMask._textureProperty, this._onSpriteChange.bind(this)); + SimpleSpriteAssembler.resetData(this._spriteData, this._getChunkManager()); this.setMaterial(this._engine._basicResources.spriteMaskDefaultMaterial); this.shaderData.setFloat(SpriteMask._alphaCutoffProperty, this._alphaCutoff); this._renderElement = new RenderElement(); this._renderElement.addSubRenderElement(new SubRenderElement()); - this._onSpriteChange = this._onSpriteChange.bind(this); } /** @@ -198,7 +180,7 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { */ override _cloneTo(target: SpriteMask): void { super._cloneTo(target); - target.sprite = this._sprite; + this._spriteData.cloneTo(target._spriteData); } /** @@ -239,16 +221,21 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { } protected override _updateBounds(worldBounds: BoundingBox): void { - const sprite = this._sprite; + const sprite = this._spriteData.sprite; if (sprite) { SimpleSpriteAssembler.updatePositions( - this, + this._spriteData, + this._getChunkManager(), + { + width: this.width, + height: this.height, + pivot: sprite.pivot, + flipX: this._flipX, + flipY: this._flipY, + referenceResolutionPerUnit: undefined + }, this._transformEntity.transform.worldMatrix, - this.width, - this.height, - sprite.pivot, - this._flipX, - this._flipY + this._bounds ); } else { const { worldPosition } = this._transformEntity.transform; @@ -261,7 +248,7 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { * @inheritdoc */ protected override _render(context: RenderContext): void { - const { _sprite: sprite } = this; + const sprite = this._spriteData.sprite; if (!sprite?.texture || !this.width || !this.height) { return; } @@ -279,20 +266,25 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { // Update position if (this._dirtyUpdateFlag & RendererUpdateFlags.WorldVolume) { SimpleSpriteAssembler.updatePositions( - this, + this._spriteData, + this._getChunkManager(), + { + width: this.width, + height: this.height, + pivot: sprite.pivot, + flipX: this._flipX, + flipY: this._flipY, + referenceResolutionPerUnit: undefined + }, this._transformEntity.transform.worldMatrix, - this.width, - this.height, - sprite.pivot, - this._flipX, - this._flipY + this._bounds ); this._dirtyUpdateFlag &= ~RendererUpdateFlags.WorldVolume; } // Update uv if (this._dirtyUpdateFlag & SpriteMaskUpdateFlags.UV) { - SimpleSpriteAssembler.updateUVs(this); + SimpleSpriteAssembler.updateUVs(this._spriteData); this._dirtyUpdateFlag &= ~SpriteMaskUpdateFlags.UV; } @@ -300,8 +292,8 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { const subRenderElement = renderElement.subRenderElements[0]; renderElement.set(this.priority, this._distanceForSort); - const subChunk = this._subChunk; - subRenderElement.set(this, material, subChunk.chunk.primitive, subChunk.subMesh, this.sprite.texture, subChunk); + const subChunk = this._spriteData.subChunk; + subRenderElement.set(this, material, subChunk.chunk.primitive, subChunk.subMesh, sprite.texture, subChunk); subRenderElement.shaderPasses = material.shader.subShaders[0].passes; subRenderElement.renderQueueFlags = RenderQueueFlags.All; renderElement.addSubRenderElement(subRenderElement); @@ -311,25 +303,15 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { * @inheritdoc */ protected override _onDestroy(): void { - const sprite = this._sprite; - if (sprite) { - this._addResourceReferCount(sprite, -1); - sprite._updateFlagManager.removeListener(this._onSpriteChange); - } + this._spriteData.destroy(this._getChunkManager()); super._onDestroy(); - this._sprite = null; - if (this._subChunk) { - this._getChunkManager().freeSubChunk(this._subChunk); - this._subChunk = null; - } - this._renderElement = null; } private _calDefaultSize(): void { - const sprite = this._sprite; + const sprite = this._spriteData.sprite; if (sprite) { this._automaticWidth = sprite.width; this._automaticHeight = sprite.height; @@ -340,11 +322,13 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { } @ignoreClone - private _onSpriteChange(type: SpriteModifyFlags): void { + private _onSpriteChange(type: SpriteModifyFlags | null): void { + if (type === null) { + // Sprite instance replaced + this._dirtyUpdateFlag |= SpriteMaskUpdateFlags.All; + return; + } switch (type) { - case SpriteModifyFlags.texture: - this.shaderData.setTexture(SpriteMask._textureProperty, this.sprite.texture); - break; case SpriteModifyFlags.size: this._dirtyUpdateFlag |= SpriteMaskUpdateFlags.AutomaticSize; if (this._customWidth === undefined || this._customHeight === undefined) { @@ -361,9 +345,6 @@ export class SpriteMask extends Renderer implements ISpriteRenderer { case SpriteModifyFlags.pivot: this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; break; - case SpriteModifyFlags.destroy: - this.sprite = null; - break; default: break; } diff --git a/packages/core/src/2d/sprite/SpriteDataBinding.ts b/packages/core/src/2d/sprite/SpritePrimitive.ts similarity index 76% rename from packages/core/src/2d/sprite/SpriteDataBinding.ts rename to packages/core/src/2d/sprite/SpritePrimitive.ts index 432ab3f5a8..a54e2dd8d2 100644 --- a/packages/core/src/2d/sprite/SpriteDataBinding.ts +++ b/packages/core/src/2d/sprite/SpritePrimitive.ts @@ -1,27 +1,31 @@ -import { ShaderProperty } from "../../shader/ShaderProperty"; +import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManager"; +import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; import { ShaderData } from "../../shader/ShaderData"; +import { ShaderProperty } from "../../shader/ShaderProperty"; import { SpriteModifyFlags } from "../enums/SpriteModifyFlags"; import { Sprite } from "./Sprite"; /** - * Minimal host interface required by SpriteDataBinding. + * Minimal host interface required by SpritePrimitive. * Both Renderer and UIRenderer satisfy this. */ -export interface ISpriteDataBindingOwner { +export interface ISpritePrimitiveOwner { shaderData: ShaderData; _addResourceReferCount(resource: any, count: number): void; } /** * Manages sprite reference lifecycle: ref counting, change listener registration, - * and texture shader property binding. + * texture shader property binding, and sub-chunk ownership. * * Shared by SpriteRenderable (SpriteRenderer/Image) and future SpriteMaskRenderable (SpriteMask/UIMask). - * Does NOT own _subChunk or dirty flags — those stay on the host for ISpriteRenderer compatibility. */ -export class SpriteDataBinding { +export class SpritePrimitive { + /** The sub-chunk allocated for this sprite's vertex data. */ + subChunk: SubPrimitiveChunk = null; + private _sprite: Sprite = null; - private _owner: ISpriteDataBindingOwner; + private _owner: ISpritePrimitiveOwner; private _textureProperty: ShaderProperty; private _onSpriteChanged: (type: SpriteModifyFlags | null) => void; @@ -53,13 +57,13 @@ export class SpriteDataBinding { } /** - * @param owner - The renderer that owns this core + * @param owner - The renderer that owns this primitive * @param textureProperty - Shader property for texture binding * @param onSpriteChanged - Callback for sprite changes. `null` type = sprite instance replaced; otherwise specific property changed. * texture and destroy are handled internally and NOT forwarded. */ constructor( - owner: ISpriteDataBindingOwner, + owner: ISpritePrimitiveOwner, textureProperty: ShaderProperty, onSpriteChanged: (type: SpriteModifyFlags | null) => void ) { @@ -70,16 +74,21 @@ export class SpriteDataBinding { } /** - * Clone sprite reference to target core. Triggers target's setter (ref counting + listener). + * Clone sprite reference to target primitive. Triggers target's setter (ref counting + listener). */ - cloneTo(target: SpriteDataBinding): void { + cloneTo(target: SpritePrimitive): void { target.sprite = this._sprite; } /** - * Release sprite reference and listeners. Call from host's _onDestroy. + * Release sprite reference, listeners, and sub-chunk. Call from host's _onDestroy. */ - destroy(): void { + destroy(chunkManager: PrimitiveChunkManager): void { + if (this.subChunk) { + chunkManager.freeSubChunk(this.subChunk); + this.subChunk = null; + } + const sprite = this._sprite; if (sprite) { this._owner._addResourceReferCount(sprite, -1); diff --git a/packages/core/src/2d/sprite/SpriteRenderable.ts b/packages/core/src/2d/sprite/SpriteRenderable.ts index 933ea56be5..c0c33d82fb 100644 --- a/packages/core/src/2d/sprite/SpriteRenderable.ts +++ b/packages/core/src/2d/sprite/SpriteRenderable.ts @@ -10,7 +10,6 @@ import { Material } from "../../material"; import { ShaderProperty } from "../../shader/ShaderProperty"; import { Texture2D } from "../../texture"; import { ISpriteAssembler } from "../assembler/ISpriteAssembler"; -import { ISpriteRenderer } from "../assembler/ISpriteRenderer"; import { SimpleSpriteAssembler } from "../assembler/SimpleSpriteAssembler"; import { SlicedSpriteAssembler } from "../assembler/SlicedSpriteAssembler"; import { TiledSpriteAssembler } from "../assembler/TiledSpriteAssembler"; @@ -19,7 +18,7 @@ import { SpriteModifyFlags } from "../enums/SpriteModifyFlags"; import { SpriteTileMode } from "../enums/SpriteTileMode"; import { ISpriteLayout } from "./ISpriteLayout"; import { Sprite } from "./Sprite"; -import { SpriteDataBinding } from "./SpriteDataBinding"; +import { SpritePrimitive } from "./SpritePrimitive"; /** * @remarks Extends `RendererUpdateFlags`. @@ -48,19 +47,24 @@ export interface ISpriteRenderable { drawMode: SpriteDrawMode; tileMode: SpriteTileMode; tiledAdaptiveThreshold: number; - _subChunk: SubPrimitiveChunk; - _dataBinding: SpriteDataBinding; - _layout: ISpriteLayout; + _spriteData: SpritePrimitive; _getChunkManager(): PrimitiveChunkManager; _getDefaultSpriteMaterial(): Material; _getSpriteAlpha(): number; + _getSpriteWidth(): number; + _getSpriteHeight(): number; + _getSpritePivot(): Vector2; + _getSpriteFlipX(): boolean; + _getSpriteFlipY(): boolean; + _getReferenceResolutionPerUnit(): number | undefined; + _onSpriteSizeChanged(): void; + _onSpritePivotChanged(): void; _submitSpriteRenderElement( context: RenderContext, material: Material, subChunk: SubPrimitiveChunk, texture: Texture2D ): void; - _createLayout(): ISpriteLayout; _initSpriteRenderable(textureProperty: ShaderProperty): void; } @@ -71,19 +75,25 @@ export interface ISpriteRenderable { * All host-specific behavior is accessed through abstract methods, composition objects, and hooks. * The mixin NEVER touches host private fields directly. */ +type MutableSpriteLayout = { -readonly [K in keyof ISpriteLayout]: ISpriteLayout[K] }; + export function SpriteRenderable( Base: T ): (abstract new (...args: any[]) => ISpriteRenderable) & T { - abstract class SpriteRenderableHost extends Base implements ISpriteRenderer { - /** @internal */ - @ignoreClone - _subChunk: SubPrimitiveChunk; - /** @internal */ - @ignoreClone - _dataBinding: SpriteDataBinding; + abstract class SpriteRenderableHost extends Base { + /** Static cached layout object for assembler calls. */ + private static _layoutCache: MutableSpriteLayout = { + width: 0, + height: 0, + pivot: null, + flipX: false, + flipY: false, + referenceResolutionPerUnit: undefined + }; + /** @internal */ @ignoreClone - _layout: ISpriteLayout; + _spriteData: SpritePrimitive; @ignoreClone private _drawMode: SpriteDrawMode; @@ -113,8 +123,11 @@ export function SpriteRenderable( texture: Texture2D ): void; - /** Create the layout for this host type. */ - abstract _createLayout(): ISpriteLayout; + /** The sprite width for layout. */ + abstract _getSpriteWidth(): number; + + /** The sprite height for layout. */ + abstract _getSpriteHeight(): number; // ===== Methods with defaults: host CAN override ===== @@ -123,17 +136,43 @@ export function SpriteRenderable( return 1; } + /** Sprite pivot. Default: sprite's own pivot. */ + _getSpritePivot(): Vector2 { + return this._spriteData.sprite?.pivot; + } + + /** Whether to flip X. Default: false. */ + _getSpriteFlipX(): boolean { + return false; + } + + /** Whether to flip Y. Default: false. */ + _getSpriteFlipY(): boolean { + return false; + } + + /** Reference resolution per unit. Default: undefined. */ + _getReferenceResolutionPerUnit(): number | undefined { + return undefined; + } + + /** Called when sprite size changes. Host can override to mark dirty flags. */ + _onSpriteSizeChanged(): void {} + + /** Called when sprite pivot changes. Host can override to mark dirty flags. */ + _onSpritePivotChanged(): void {} + // ===== Public API (forwarding) ===== /** * The Sprite to render. */ get sprite(): Sprite | null { - return this._dataBinding.sprite; + return this._spriteData.sprite; } set sprite(value: Sprite | null) { - this._dataBinding.sprite = value; + this._spriteData.sprite = value; } /** @@ -159,7 +198,7 @@ export function SpriteRenderable( default: break; } - this._assembler.resetData(this); + this._assembler.resetData(this._spriteData, this._getChunkManager()); this._dirtyUpdateFlag |= SpriteRenderableFlags.WorldVolumeUVAndColor; } } @@ -205,8 +244,7 @@ export function SpriteRenderable( * @internal */ _initSpriteRenderable(textureProperty: ShaderProperty): void { - this._dataBinding = new SpriteDataBinding(this as any, textureProperty, this._onSpriteChanged.bind(this)); - this._layout = this._createLayout(); + this._spriteData = new SpritePrimitive(this as any, textureProperty, this._onSpriteChanged.bind(this)); this.drawMode = SpriteDrawMode.Simple; this._dirtyUpdateFlag |= SpriteRenderableFlags.Color; this.setMaterial(this._getDefaultSpriteMaterial()); @@ -229,7 +267,7 @@ export function SpriteRenderable( override _cloneTo(target: SpriteRenderableHost): void { // @ts-ignore super._cloneTo(target); - this._dataBinding.cloneTo(target._dataBinding); + this._spriteData.cloneTo(target._spriteData); target.drawMode = this._drawMode; } @@ -248,17 +286,15 @@ export function SpriteRenderable( } protected override _updateBounds(worldBounds: BoundingBox): void { - const layout = this._layout; - if (this._dataBinding.sprite) { + if (this._spriteData.sprite) { this._assembler.updatePositions( - this, + this._spriteData, + this._getChunkManager(), + this._fillLayout(), this._transformEntity.transform.worldMatrix, - layout.width, - layout.height, - layout.pivot, - layout.flipX, - layout.flipY, - layout.referenceResolutionPerUnit + this._bounds, + this._tileMode, + this._tiledAdaptiveThreshold ); } else { const { worldPosition } = this._transformEntity.transform; @@ -268,10 +304,9 @@ export function SpriteRenderable( } protected override _render(context: RenderContext): void { - const sprite = this._dataBinding.sprite; - const layout = this._layout; - const width = layout.width; - const height = layout.height; + const sprite = this._spriteData.sprite; + const width = this._getSpriteWidth(); + const height = this._getSpriteHeight(); if (!sprite?.texture || !width || !height) { return; } @@ -293,64 +328,71 @@ export function SpriteRenderable( // Update position if (this._dirtyUpdateFlag & RendererUpdateFlags.WorldVolume) { this._assembler.updatePositions( - this, + this._spriteData, + this._getChunkManager(), + this._fillLayout(), this._transformEntity.transform.worldMatrix, - width, - height, - layout.pivot, - layout.flipX, - layout.flipY, - layout.referenceResolutionPerUnit + this._bounds, + this._tileMode, + this._tiledAdaptiveThreshold ); this._dirtyUpdateFlag &= ~RendererUpdateFlags.WorldVolume; } // Update uv if (this._dirtyUpdateFlag & SpriteRenderableFlags.UV) { - this._assembler.updateUVs(this); + this._assembler.updateUVs(this._spriteData); this._dirtyUpdateFlag &= ~SpriteRenderableFlags.UV; } // Update color if (this._dirtyUpdateFlag & SpriteRenderableFlags.Color) { - this._assembler.updateColor(this, alpha); + this._assembler.updateColor(this._spriteData, this.color, alpha); this._dirtyUpdateFlag &= ~SpriteRenderableFlags.Color; } // Submit - this._submitSpriteRenderElement(context, material, this._subChunk, sprite.texture); + this._submitSpriteRenderElement(context, material, this._spriteData.subChunk, sprite.texture); } protected override _onDestroy(): void { - this._dataBinding.destroy(); + this._spriteData.destroy(this._getChunkManager()); this._assembler = null; - this._layout = null; - if (this._subChunk) { - this._getChunkManager().freeSubChunk(this._subChunk); - this._subChunk = null; - } super._onDestroy(); } + // ===== Private: fill layout cache ===== + + private _fillLayout(): ISpriteLayout { + const layout = SpriteRenderableHost._layoutCache; + layout.width = this._getSpriteWidth(); + layout.height = this._getSpriteHeight(); + layout.pivot = this._getSpritePivot(); + layout.flipX = this._getSpriteFlipX(); + layout.flipY = this._getSpriteFlipY(); + layout.referenceResolutionPerUnit = this._getReferenceResolutionPerUnit(); + return layout; + } + // ===== Wiring: sprite change dispatch ===== /** - * Callback from SpriteDataBinding. + * Callback from SpritePrimitive. * `type === null` means sprite instance was replaced; otherwise a specific property changed. */ private _onSpriteChanged(type: SpriteModifyFlags | null): void { if (type === null) { - // Sprite instance replaced — mark everything dirty, notify layout + // Sprite instance replaced — mark everything dirty this._dirtyUpdateFlag |= SpriteRenderableFlags.All; - this._layout.onSpriteInstanceChanged(); + this._onSpriteSizeChanged(); return; } switch (type) { case SpriteModifyFlags.size: - this._dirtyUpdateFlag |= this._layout.onSpriteSizeChanged(); + this._onSpriteSizeChanged(); switch (this._drawMode) { case SpriteDrawMode.Sliced: this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; @@ -382,7 +424,7 @@ export function SpriteRenderable( this._dirtyUpdateFlag |= SpriteRenderableFlags.UV; break; case SpriteModifyFlags.pivot: - this._dirtyUpdateFlag |= this._layout.onSpritePivotChanged(); + this._onSpritePivotChanged(); break; default: break; diff --git a/packages/core/src/2d/sprite/SpriteRenderer.ts b/packages/core/src/2d/sprite/SpriteRenderer.ts index 7f2461a39b..20f7cec4d2 100644 --- a/packages/core/src/2d/sprite/SpriteRenderer.ts +++ b/packages/core/src/2d/sprite/SpriteRenderer.ts @@ -1,4 +1,4 @@ -import { Color } from "@galacean/engine-math"; +import { Color, Vector2 } from "@galacean/engine-math"; import { Entity } from "../../Entity"; import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManager"; import { RenderContext } from "../../RenderPipeline/RenderContext"; @@ -9,11 +9,9 @@ import { SpriteMaskLayer } from "../../enums/SpriteMaskLayer"; import { Material } from "../../material"; import { ShaderProperty } from "../../shader/ShaderProperty"; import { Texture2D } from "../../texture"; -import { ISpriteLayout } from "./ISpriteLayout"; import { SpriteDrawMode } from "../enums/SpriteDrawMode"; import { SpriteMaskInteraction } from "../enums/SpriteMaskInteraction"; import { SpriteRenderable, SpriteRenderableFlags } from "./SpriteRenderable"; -import { WorldSpriteLayout } from "./WorldSpriteLayout"; /** * Renders a Sprite for 2D graphics. @@ -25,6 +23,21 @@ export class SpriteRenderer extends SpriteRenderable(Renderer) { @deepClone private _color: Color = new Color(1, 1, 1, 1); + @ignoreClone + private _customWidth: number = undefined; + @ignoreClone + private _customHeight: number = undefined; + @ignoreClone + private _automaticWidth: number = 0; + @ignoreClone + private _automaticHeight: number = 0; + @ignoreClone + private _autoSizeDirty: boolean = true; + @ignoreClone + private _flipX: boolean = false; + @ignoreClone + private _flipY: boolean = false; + /** * Rendering color for the Sprite graphic. */ @@ -46,13 +59,18 @@ export class SpriteRenderer extends SpriteRenderable(Renderer) { * otherwise return `SpriteRenderer.sprite.width`. */ get width(): number { - return (this._layout).width; + if (this._customWidth !== undefined) { + return this._customWidth; + } + if (this._autoSizeDirty) { + this._calDefaultSize(); + } + return this._automaticWidth; } set width(value: number) { - const layout = this._layout; - if (layout.customWidth !== value) { - layout.width = value; + if (this._customWidth !== value) { + this._customWidth = value; this._dirtyUpdateFlag |= this.drawMode === SpriteDrawMode.Tiled ? SpriteRenderableFlags.WorldVolumeUVAndColor @@ -68,13 +86,18 @@ export class SpriteRenderer extends SpriteRenderable(Renderer) { * otherwise return `SpriteRenderer.sprite.height`. */ get height(): number { - return (this._layout).height; + if (this._customHeight !== undefined) { + return this._customHeight; + } + if (this._autoSizeDirty) { + this._calDefaultSize(); + } + return this._automaticHeight; } set height(value: number) { - const layout = this._layout; - if (layout.customHeight !== value) { - layout.height = value; + if (this._customHeight !== value) { + this._customHeight = value; this._dirtyUpdateFlag |= this.drawMode === SpriteDrawMode.Tiled ? SpriteRenderableFlags.WorldVolumeUVAndColor @@ -86,13 +109,12 @@ export class SpriteRenderer extends SpriteRenderable(Renderer) { * Flips the sprite on the X axis. */ get flipX(): boolean { - return (this._layout).flipX; + return this._flipX; } set flipX(value: boolean) { - const layout = this._layout; - if (layout.flipX !== value) { - layout.flipX = value; + if (this._flipX !== value) { + this._flipX = value; this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; } } @@ -101,13 +123,12 @@ export class SpriteRenderer extends SpriteRenderable(Renderer) { * Flips the sprite on the Y axis. */ get flipY(): boolean { - return (this._layout).flipY; + return this._flipY; } set flipY(value: boolean) { - const layout = this._layout; - if (layout.flipY !== value) { - layout.flipY = value; + if (this._flipY !== value) { + this._flipY = value; this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; } } @@ -176,12 +197,56 @@ export class SpriteRenderer extends SpriteRenderable(Renderer) { } /** @internal */ - override _createLayout(): ISpriteLayout { - return new WorldSpriteLayout(() => this.sprite); + override _getSpriteWidth(): number { + return this.width; + } + + /** @internal */ + override _getSpriteHeight(): number { + return this.height; + } + + /** @internal */ + override _getSpritePivot(): Vector2 { + return this.sprite?.pivot; + } + + /** @internal */ + override _getSpriteFlipX(): boolean { + return this._flipX; + } + + /** @internal */ + override _getSpriteFlipY(): boolean { + return this._flipY; + } + + /** @internal */ + override _onSpriteSizeChanged(): void { + this._autoSizeDirty = true; + if (this._customWidth === undefined || this._customHeight === undefined) { + this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; + } + } + + /** @internal */ + override _onSpritePivotChanged(): void { + this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; } // ===== Private ===== + private _calDefaultSize(): void { + const sprite = this.sprite; + if (sprite) { + this._automaticWidth = sprite.width; + this._automaticHeight = sprite.height; + } else { + this._automaticWidth = this._automaticHeight = 0; + } + this._autoSizeDirty = false; + } + @ignoreClone private _onColorChanged(): void { this._dirtyUpdateFlag |= SpriteRenderableFlags.Color; diff --git a/packages/core/src/2d/sprite/WorldSpriteLayout.ts b/packages/core/src/2d/sprite/WorldSpriteLayout.ts deleted file mode 100644 index 027cb9dd18..0000000000 --- a/packages/core/src/2d/sprite/WorldSpriteLayout.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { Vector2 } from "@galacean/engine-math"; -import { RendererUpdateFlags } from "../../Renderer"; -import { Sprite } from "./Sprite"; -import { ISpriteLayout } from "./ISpriteLayout"; - -/** - * Layout for world-space sprite renderers (SpriteRenderer, SpriteMask). - * Provides custom/automatic width-height, flipX/flipY, and sprite.pivot. - */ -export class WorldSpriteLayout implements ISpriteLayout { - private _customWidth: number = undefined; - private _customHeight: number = undefined; - private _automaticWidth: number = 0; - private _automaticHeight: number = 0; - private _autoSizeDirty: boolean = true; - private _flipX: boolean = false; - private _flipY: boolean = false; - private _spriteGetter: () => Sprite | null; - - constructor(spriteGetter: () => Sprite | null) { - this._spriteGetter = spriteGetter; - } - - // --- Width / Height --- - - get width(): number { - if (this._customWidth !== undefined) { - return this._customWidth; - } else { - if (this._autoSizeDirty) { - this._calDefaultSize(); - } - return this._automaticWidth; - } - } - - set width(value: number) { - this._customWidth = value; - } - - get height(): number { - if (this._customHeight !== undefined) { - return this._customHeight; - } else { - if (this._autoSizeDirty) { - this._calDefaultSize(); - } - return this._automaticHeight; - } - } - - set height(value: number) { - this._customHeight = value; - } - - get customWidth(): number { - return this._customWidth; - } - - get customHeight(): number { - return this._customHeight; - } - - // --- Flip --- - - get flipX(): boolean { - return this._flipX; - } - - set flipX(value: boolean) { - this._flipX = value; - } - - get flipY(): boolean { - return this._flipY; - } - - set flipY(value: boolean) { - this._flipY = value; - } - - // --- ISpriteLayout --- - - get pivot(): Vector2 { - return this._spriteGetter()?.pivot; - } - - get referenceResolutionPerUnit(): number | undefined { - return undefined; - } - - onSpriteInstanceChanged(): void { - this._autoSizeDirty = true; - } - - onSpriteSizeChanged(): number { - this._autoSizeDirty = true; - if (this._customWidth === undefined || this._customHeight === undefined) { - return RendererUpdateFlags.WorldVolume; - } - return 0; - } - - onSpritePivotChanged(): number { - return RendererUpdateFlags.WorldVolume; - } - - // --- Private --- - - private _calDefaultSize(): void { - const sprite = this._spriteGetter(); - if (sprite) { - this._automaticWidth = sprite.width; - this._automaticHeight = sprite.height; - } else { - this._automaticWidth = this._automaticHeight = 0; - } - this._autoSizeDirty = false; - } -} diff --git a/packages/core/src/2d/sprite/index.ts b/packages/core/src/2d/sprite/index.ts index a1f5c54ab3..056166c6e1 100644 --- a/packages/core/src/2d/sprite/index.ts +++ b/packages/core/src/2d/sprite/index.ts @@ -1,9 +1,8 @@ export type { ISpriteLayout } from "./ISpriteLayout"; export { Sprite } from "./Sprite"; -export { SpriteDataBinding } from "./SpriteDataBinding"; -export type { ISpriteDataBindingOwner } from "./SpriteDataBinding"; +export { SpritePrimitive } from "./SpritePrimitive"; +export type { ISpritePrimitiveOwner } from "./SpritePrimitive"; export type { ISpriteRenderable } from "./SpriteRenderable"; export { SpriteRenderable, SpriteRenderableFlags } from "./SpriteRenderable"; export { SpriteMask } from "./SpriteMask"; export { SpriteRenderer } from "./SpriteRenderer"; -export { WorldSpriteLayout } from "./WorldSpriteLayout"; diff --git a/packages/core/src/2d/text/TextRenderable.ts b/packages/core/src/2d/text/TextRenderable.ts index 3187bbd117..fb99f8d1f6 100644 --- a/packages/core/src/2d/text/TextRenderable.ts +++ b/packages/core/src/2d/text/TextRenderable.ts @@ -8,13 +8,11 @@ import { SubRenderElement } from "../../RenderPipeline/SubRenderElement"; import { Renderer, RendererUpdateFlags } from "../../Renderer"; import { assignmentClone, ignoreClone } from "../../clone/CloneManager"; import { Material } from "../../material"; -import { ShaderData, ShaderProperty } from "../../shader"; -import { ShaderDataGroup } from "../../shader/enums/ShaderDataGroup"; +import { ShaderProperty } from "../../shader"; import { Texture2D } from "../../texture"; import { FontStyle } from "../enums/FontStyle"; import { TextHorizontalAlignment, TextVerticalAlignment } from "../enums/TextAlignment"; import { OverflowMode } from "../enums/TextOverflow"; -import { ISpriteLayout } from "../sprite/ISpriteLayout"; import { CharRenderInfo } from "./CharRenderInfo"; import { Font } from "./Font"; import { ITextRenderer } from "./ITextRenderer"; @@ -58,11 +56,14 @@ export interface ITextRenderable { verticalAlignment: TextVerticalAlignment; enableWrapping: boolean; overflowMode: OverflowMode; - _layout: ISpriteLayout; _subFont: SubFont; _getChunkManager(): PrimitiveChunkManager; _getSubFont(): SubFont; - _createLayout(): ISpriteLayout; + _getTextWidth(): number; + _getTextHeight(): number; + _getTextPivotX(): number; + _getTextPivotY(): number; + _getTextReferenceResolutionPerUnit(): number | undefined; _getTextAlpha(): number; _submitText(context: RenderContext, material: Material): void; _isTextHostInvisible(): boolean; @@ -87,9 +88,6 @@ export function TextRenderable( private static _worldPositions = [new Vector3(), new Vector3(), new Vector3(), new Vector3()]; private static _charRenderInfos: CharRenderInfo[] = []; - /** @internal */ - @ignoreClone - _layout: ISpriteLayout; @ignoreClone private _textChunks = Array(); /** @internal */ @@ -122,9 +120,14 @@ export function TextRenderable( abstract get color(): Color; abstract _getChunkManager(): PrimitiveChunkManager; - abstract _createLayout(): ISpriteLayout; abstract _submitText(context: RenderContext, material: Material): void; + /** The text layout width. */ + abstract _getTextWidth(): number; + + /** The text layout height. */ + abstract _getTextHeight(): number; + // ===== Methods with defaults ===== _getTextAlpha(): number { @@ -135,6 +138,21 @@ export function TextRenderable( return false; } + /** Text pivot X. Default: 0.5. */ + _getTextPivotX(): number { + return 0.5; + } + + /** Text pivot Y. Default: 0.5. */ + _getTextPivotY(): number { + return 0.5; + } + + /** Reference resolution per unit. Default: undefined (no scaling). */ + _getTextReferenceResolutionPerUnit(): number | undefined { + return undefined; + } + // ===== Text properties ===== get text(): string { @@ -277,7 +295,6 @@ export function TextRenderable( _initTextRenderable(): void { this.font = this._engine._textDefaultFont; this.setMaterial(this._engine._basicResources.textDefaultMaterial); - this._layout = this._createLayout(); } // ===== Lifecycle ===== @@ -350,7 +367,6 @@ export function TextRenderable( this._freeTextChunks(); this._textChunks = null; this._subFont && (this._subFont = null); - this._layout = null; } @ignoreClone @@ -393,13 +409,14 @@ export function TextRenderable( // ===== Private ===== private _isTextNoVisible(): boolean { - const layout = this._layout; + const textWidth = this._getTextWidth(); + const textHeight = this._getTextHeight(); return ( !this._font || this._text === "" || this._fontSize === 0 || - (this._enableWrapping && layout.width <= 0) || - (this._overflowMode === OverflowMode.Truncate && layout.height <= 0) || + (this._enableWrapping && textWidth <= 0) || + (this._overflowMode === OverflowMode.Truncate && textHeight <= 0) || this._isTextHostInvisible() ); } @@ -480,14 +497,14 @@ export function TextRenderable( } private _updateLocalData(): void { - const layout = this._layout; - let rendererWidth = layout.width; - let rendererHeight = layout.height; - const { pivot } = layout; - const resPerUnit = layout.referenceResolutionPerUnit; + let rendererWidth = this._getTextWidth(); + let rendererHeight = this._getTextHeight(); + const pivotX = this._getTextPivotX(); + const pivotY = this._getTextPivotY(); + const resPerUnit = this._getTextReferenceResolutionPerUnit(); const pixelsPerUnit = resPerUnit ? Engine._pixelsPerUnit / resPerUnit : Engine._pixelsPerUnit; - const offsetWidth = rendererWidth * (0.5 - pivot.x); - const offsetHeight = rendererHeight * (0.5 - pivot.y); + const offsetWidth = rendererWidth * (0.5 - pivotX); + const offsetHeight = rendererHeight * (0.5 - pivotY); const { min, max } = this._localBounds; const charRenderInfos = TextRenderableHost._charRenderInfos; diff --git a/packages/core/src/2d/text/TextRenderer.ts b/packages/core/src/2d/text/TextRenderer.ts index b85c4c4259..8f104b8344 100644 --- a/packages/core/src/2d/text/TextRenderer.ts +++ b/packages/core/src/2d/text/TextRenderer.ts @@ -8,10 +8,8 @@ import { SpriteMaskLayer } from "../../enums/SpriteMaskLayer"; import { ShaderData } from "../../shader"; import { ShaderDataGroup } from "../../shader/enums/ShaderDataGroup"; import { SpriteMaskInteraction } from "../enums/SpriteMaskInteraction"; -import { ISpriteLayout } from "../sprite/ISpriteLayout"; import { Material } from "../../material"; import { TextChunk, TextRenderable, TextRenderableFlags } from "./TextRenderable"; -import { WorldTextLayout } from "./WorldTextLayout"; /** * Renders a text for 2D graphics. @@ -20,6 +18,9 @@ export class TextRenderer extends TextRenderable(Renderer) { @deepClone private _color = new Color(1, 1, 1, 1); + private _width: number = 0; + private _height: number = 0; + /** * Rendering color for the Text. */ @@ -37,13 +38,12 @@ export class TextRenderer extends TextRenderable(Renderer) { * The width of the TextRenderer (in 3D world coordinates). */ get width(): number { - return (this._layout).width; + return this._width; } set width(value: number) { - const layout = this._layout; - if (layout.width !== value) { - layout.width = value; + if (this._width !== value) { + this._width = value; this._setDirtyFlagTrue(TextRenderableFlags.Position); } } @@ -52,13 +52,12 @@ export class TextRenderer extends TextRenderable(Renderer) { * The height of the TextRenderer (in 3D world coordinates). */ get height(): number { - return (this._layout).height; + return this._height; } set height(value: number) { - const layout = this._layout; - if (layout.height !== value) { - layout.height = value; + if (this._height !== value) { + this._height = value; this._setDirtyFlagTrue(TextRenderableFlags.Position); } } @@ -101,8 +100,12 @@ export class TextRenderer extends TextRenderable(Renderer) { return this.engine._batcherManager.primitiveChunkManager2D; } - override _createLayout(): ISpriteLayout { - return new WorldTextLayout(); + override _getTextWidth(): number { + return this._width; + } + + override _getTextHeight(): number { + return this._height; } override _submitText(context: RenderContext, material: Material): void { diff --git a/packages/core/src/2d/text/WorldTextLayout.ts b/packages/core/src/2d/text/WorldTextLayout.ts deleted file mode 100644 index 639e2e3cb3..0000000000 --- a/packages/core/src/2d/text/WorldTextLayout.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Vector2 } from "@galacean/engine-math"; -import { ISpriteLayout } from "../sprite/ISpriteLayout"; - -/** - * Layout for world-space text renderers (TextRenderer). - * Provides custom width/height, centered pivot (0.5, 0.5), no flip. - */ -export class WorldTextLayout implements ISpriteLayout { - private static _defaultPivot = new Vector2(0.5, 0.5); - private _width: number = 0; - private _height: number = 0; - - get width(): number { - return this._width; - } - - set width(value: number) { - this._width = value; - } - - get height(): number { - return this._height; - } - - set height(value: number) { - this._height = value; - } - - get pivot(): Vector2 { - return WorldTextLayout._defaultPivot; - } - - get flipX(): boolean { - return false; - } - - get flipY(): boolean { - return false; - } - - get referenceResolutionPerUnit(): number | undefined { - return undefined; - } - - onSpriteSizeChanged(): number { - return 0; - } - - onSpritePivotChanged(): number { - return 0; - } - - onSpriteInstanceChanged(): void {} -} diff --git a/packages/core/src/2d/text/index.ts b/packages/core/src/2d/text/index.ts index 89f6ab02ee..8b412e006b 100644 --- a/packages/core/src/2d/text/index.ts +++ b/packages/core/src/2d/text/index.ts @@ -2,7 +2,6 @@ export { Font } from "./Font"; export type { ITextRenderable } from "./TextRenderable"; export { TextChunk, TextRenderable, TextRenderableFlags } from "./TextRenderable"; export { TextRenderer } from "./TextRenderer"; -export { WorldTextLayout } from "./WorldTextLayout"; // For set TextUtils._extendHeight used to extend the height of canvas, because in miniprogram performance is different from h5 export { CharRenderInfo } from "./CharRenderInfo"; export { SubFont } from "./SubFont"; diff --git a/packages/ui/src/component/advanced/Image.ts b/packages/ui/src/component/advanced/Image.ts index 8a21cd4f3a..cb94a7a640 100644 --- a/packages/ui/src/component/advanced/Image.ts +++ b/packages/ui/src/component/advanced/Image.ts @@ -2,7 +2,6 @@ import { BoundingBox, Entity, Material, - PrimitiveChunkManager, RenderContext, RenderQueueFlags, RendererUpdateFlags, @@ -13,12 +12,11 @@ import { Texture2D, ignoreClone } from "@galacean/engine"; -import type { ISpriteLayout, ISpriteRenderable } from "@galacean/engine"; +import type { Vector2 } from "@galacean/engine"; import { CanvasRenderMode } from "../../enums/CanvasRenderMode"; import { RootCanvasModifyFlags } from "../UICanvas"; import { UIRenderer } from "../UIRenderer"; import { UITransform, UITransformModifyFlags } from "../UITransform"; -import { UISpriteLayout } from "./UISpriteLayout"; /** * UI element that renders an image. @@ -63,11 +61,18 @@ export class Image extends SpriteRenderable(UIRenderer) { } /** @internal */ - override _createLayout(): ISpriteLayout { - return new UISpriteLayout( - () => this._transformEntity.transform, - () => this._getRootCanvas() - ); + override _getSpriteWidth(): number { + return (this._transformEntity.transform).size.x; + } + + /** @internal */ + override _getSpriteHeight(): number { + return (this._transformEntity.transform).size.y; + } + + /** @internal */ + override _getSpritePivot(): Vector2 { + return (this._transformEntity.transform).pivot; } // ===== Override defaults ===== @@ -76,6 +81,10 @@ export class Image extends SpriteRenderable(UIRenderer) { return this._getGlobalAlpha(); } + override _getReferenceResolutionPerUnit(): number | undefined { + return this._getRootCanvas()?.referenceResolutionPerUnit; + } + // ===== Image-specific ===== /** diff --git a/packages/ui/src/component/advanced/Text.ts b/packages/ui/src/component/advanced/Text.ts index 0bcad904dd..8192f87952 100644 --- a/packages/ui/src/component/advanced/Text.ts +++ b/packages/ui/src/component/advanced/Text.ts @@ -4,20 +4,16 @@ import { Material, RenderContext, RenderQueueFlags, - RendererUpdateFlags, ShaderData, ShaderDataGroup, - TextChunk, TextRenderable, TextRenderableFlags, ignoreClone } from "@galacean/engine"; -import type { ISpriteLayout, ITextRenderable } from "@galacean/engine"; import { CanvasRenderMode } from "../../enums/CanvasRenderMode"; import { RootCanvasModifyFlags } from "../UICanvas"; import { UIRenderer } from "../UIRenderer"; import { UITransform, UITransformModifyFlags } from "../UITransform"; -import { UISpriteLayout } from "./UISpriteLayout"; /** * UI component used to render text. @@ -31,11 +27,24 @@ export class Text extends TextRenderable(UIRenderer) { // ===== Abstract implementations ===== - override _createLayout(): ISpriteLayout { - return new UISpriteLayout( - () => this._transformEntity.transform, - () => this._getRootCanvas() - ); + override _getTextWidth(): number { + return (this._transformEntity.transform).size.x; + } + + override _getTextHeight(): number { + return (this._transformEntity.transform).size.y; + } + + override _getTextPivotX(): number { + return (this._transformEntity.transform).pivot.x; + } + + override _getTextPivotY(): number { + return (this._transformEntity.transform).pivot.y; + } + + override _getTextReferenceResolutionPerUnit(): number | undefined { + return this._getRootCanvas()?.referenceResolutionPerUnit; } override _submitText(context: RenderContext, material: Material): void { diff --git a/packages/ui/src/component/advanced/UISpriteLayout.ts b/packages/ui/src/component/advanced/UISpriteLayout.ts deleted file mode 100644 index 96521292fb..0000000000 --- a/packages/ui/src/component/advanced/UISpriteLayout.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Vector2 } from "@galacean/engine"; -import type { ISpriteLayout } from "@galacean/engine"; -import { UICanvas } from "../UICanvas"; -import { UITransform } from "../UITransform"; - -/** - * Layout for UI-space sprite renderers (Image, UI Mask). - * Reads size/pivot from UITransform and referenceResolutionPerUnit from UICanvas. - */ -export class UISpriteLayout implements ISpriteLayout { - private _transformGetter: () => UITransform; - private _canvasGetter: () => UICanvas | null; - - constructor(transformGetter: () => UITransform, canvasGetter: () => UICanvas | null) { - this._transformGetter = transformGetter; - this._canvasGetter = canvasGetter; - } - - get width(): number { - return this._transformGetter().size.x; - } - - get height(): number { - return this._transformGetter().size.y; - } - - get pivot(): Vector2 { - return this._transformGetter().pivot; - } - - get flipX(): boolean { - return false; - } - - get flipY(): boolean { - return false; - } - - get referenceResolutionPerUnit(): number | undefined { - return this._canvasGetter()?.referenceResolutionPerUnit; - } - - onSpriteInstanceChanged(): void { - // UI sprites don't track automatic size from sprite — size comes from UITransform. - } - - onSpriteSizeChanged(): number { - // Sprite.size changes don't affect UI renderers (they use UITransform.size). - return 0; - } - - onSpritePivotChanged(): number { - // Sprite.pivot changes don't affect UI renderers (they use UITransform.pivot). - return 0; - } -} From 5e9581c0a213483a5a5f405bdec8d1cf4d5b27c2 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Thu, 19 Mar 2026 21:27:20 +0800 Subject: [PATCH 4/9] feat: update code --- examples/src/text-chunk-leak.ts | 157 ++++++++++++++++++ .../core/src/2d/assembler/ISpriteAssembler.ts | 10 +- .../src/2d/assembler/SimpleSpriteAssembler.ts | 10 +- .../src/2d/assembler/SlicedSpriteAssembler.ts | 14 +- .../src/2d/assembler/TiledSpriteAssembler.ts | 12 +- packages/core/src/2d/sprite/ISpriteLayout.ts | 13 -- packages/core/src/2d/sprite/SpriteMask.ts | 26 ++- .../core/src/2d/sprite/SpriteRenderable.ts | 40 ++--- packages/core/src/2d/sprite/index.ts | 1 - tests/src/core/SpriteMask.test.ts | 2 +- tests/src/core/SpriteRenderer.test.ts | 10 +- 11 files changed, 213 insertions(+), 82 deletions(-) create mode 100644 examples/src/text-chunk-leak.ts delete mode 100644 packages/core/src/2d/sprite/ISpriteLayout.ts diff --git a/examples/src/text-chunk-leak.ts b/examples/src/text-chunk-leak.ts new file mode 100644 index 0000000000..ce6e47c22e --- /dev/null +++ b/examples/src/text-chunk-leak.ts @@ -0,0 +1,157 @@ +/** + * @title Text Chunk Leak + * @category 2D + */ +import { + Camera, + Color, + Entity, + Script, + WebGLEngine, + TextHorizontalAlignment, +} from "@galacean/engine"; +import { + CanvasRenderMode, + registerGUI, + Text, + UICanvas, + UITransform, +} from "@galacean/engine-ui"; + +registerGUI(); + +/** + * Bug reproduction: + * + * Text components share a single PrimitiveChunk (vertex buffer). + * When entity.isActive = false, _onDisableInScene does NOT call + * _freeTextChunks(), so the inactive Text's vertices remain in the + * shared buffer and are still drawn. + * + * Steps: + * 1. Text A ("得分:48") renders at top (allocates SubChunk in shared buffer) + * 2. After 3s, Text A's parent is disabled (isActive=false) + * → SubChunk NOT freed, vertices remain in buffer + * 3. Text B ("历史最高:48") activates at center + * → allocates new SubChunk in SAME PrimitiveChunk + * 4. Draw call submits entire buffer → Text A's old vertices still drawn + */ +class Controller extends Script { + textA_parent: Entity; + textB_parent: Entity; + textAScore: Text; + + private _elapsed = 0; + private _score = 0; + private _switched = false; + + onUpdate(dt: number) { + this._elapsed += dt; + + if (!this._switched) { + // Update score every 0.3s to trigger chunk reallocation + if (this._elapsed > 0.3) { + this._elapsed -= 0.3; + this._score += Math.floor(Math.random() * 10) + 1; + this.textAScore.text = "" + this._score; + } + // After score > 40, switch panels + if (this._score > 40) { + this._switched = true; + // Hide panel A → chunks NOT freed (the bug) + this.textA_parent.isActive = false; + // Show panel B + this.textB_parent.isActive = true; + } + } + } +} + +async function main() { + const engine = await WebGLEngine.create({ + canvas: document.getElementById("canvas") as HTMLCanvasElement, + }); + engine.canvas.resizeByClientSize(); + + const scene = engine.sceneManager.activeScene; + scene.background.solidColor.set(0.35, 0.3, 0.25, 1); + const rootEntity = scene.createRootEntity(); + + const cameraEntity = rootEntity.createChild("camera"); + cameraEntity.transform.setPosition(0, 0, 10); + const camera = cameraEntity.addComponent(Camera); + camera.isOrthographic = true; + camera.orthographicSize = 5; + + // UICanvas + const canvasEntity = rootEntity.createChild("Canvas"); + const uiCanvas = canvasEntity.addComponent(UICanvas); + uiCanvas.renderMode = CanvasRenderMode.ScreenSpaceOverlay; + uiCanvas.referenceResolutionX = 750; + uiCanvas.referenceResolutionY = 1334; + + // ========== Panel A: HUD (visible initially) ========== + const panelA = canvasEntity.createChild("panelA"); + + const labelA = panelA.createChild("labelA"); + labelA.getComponent(UITransform).setPosition(-40, 300, 0); + const textA = labelA.addComponent(Text); + textA.text = "得分:"; + textA.fontSize = 36; + textA.color = new Color(1, 1, 1, 1); + textA.enableWrapping = true; + + const scoreA = panelA.createChild("scoreA"); + scoreA.getComponent(UITransform).setPosition(60, 300, 0); + const textAScore = scoreA.addComponent(Text); + textAScore.text = "0"; + textAScore.fontSize = 36; + textAScore.color = new Color(1, 1, 1, 1); + textAScore.enableWrapping = true; + + // ========== Panel B: GameOver (hidden initially) ========== + const panelB = canvasEntity.createChild("panelB"); + panelB.isActive = false; + + const labelB1 = panelB.createChild("labelB1"); + labelB1.getComponent(UITransform).setPosition(-60, 50, 0); + const textB1 = labelB1.addComponent(Text); + textB1.text = "历史最高:"; + textB1.fontSize = 40; + textB1.color = new Color(1, 1, 1, 1); + textB1.enableWrapping = true; + + const scoreB1 = panelB.createChild("scoreB1"); + scoreB1.getComponent(UITransform).setPosition(120, 50, 0); + const textBScore1 = scoreB1.addComponent(Text); + textBScore1.text = "99"; + textBScore1.fontSize = 40; + textBScore1.color = new Color(1, 1, 1, 1); + textBScore1.enableWrapping = true; + + const labelB2 = panelB.createChild("labelB2"); + labelB2.getComponent(UITransform).setPosition(-60, -50, 0); + const textB2 = labelB2.addComponent(Text); + textB2.text = "当前得分:"; + textB2.fontSize = 40; + textB2.color = new Color(1, 1, 1, 1); + textB2.enableWrapping = true; + + const scoreB2 = panelB.createChild("scoreB2"); + scoreB2.getComponent(UITransform).setPosition(120, -50, 0); + const textBScore2 = scoreB2.addComponent(Text); + textBScore2.text = "0"; + textBScore2.fontSize = 40; + textBScore2.color = new Color(1, 1, 1, 1); + textBScore2.enableWrapping = true; + + // ========== Controller ========== + const ctrl = rootEntity.addComponent(Controller); + ctrl.textA_parent = panelA; + ctrl.textB_parent = panelB; + ctrl.textAScore = textAScore; + + engine.run(); +} + +main(); diff --git a/packages/core/src/2d/assembler/ISpriteAssembler.ts b/packages/core/src/2d/assembler/ISpriteAssembler.ts index 38ecfb93c3..6d38637b20 100644 --- a/packages/core/src/2d/assembler/ISpriteAssembler.ts +++ b/packages/core/src/2d/assembler/ISpriteAssembler.ts @@ -1,7 +1,6 @@ -import { BoundingBox, Color, Matrix } from "@galacean/engine-math"; +import { BoundingBox, Color, Matrix, Vector2 } from "@galacean/engine-math"; import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManager"; import { SpriteTileMode } from "../enums/SpriteTileMode"; -import { ISpriteLayout } from "../sprite/ISpriteLayout"; import { SpritePrimitive } from "../sprite/SpritePrimitive"; /** @@ -12,9 +11,14 @@ export interface ISpriteAssembler { updatePositions( primitive: SpritePrimitive, chunkManager: PrimitiveChunkManager, - layout: ISpriteLayout, worldMatrix: Matrix, + width: number, + height: number, + pivot: Vector2, + flipX: boolean, + flipY: boolean, outBounds: BoundingBox, + referenceResolutionPerUnit?: number, tileMode?: SpriteTileMode, tiledAdaptiveThreshold?: number ): void; diff --git a/packages/core/src/2d/assembler/SimpleSpriteAssembler.ts b/packages/core/src/2d/assembler/SimpleSpriteAssembler.ts index b6bc07e60a..8ffb875a17 100644 --- a/packages/core/src/2d/assembler/SimpleSpriteAssembler.ts +++ b/packages/core/src/2d/assembler/SimpleSpriteAssembler.ts @@ -1,7 +1,6 @@ -import { BoundingBox, Color, Matrix } from "@galacean/engine-math"; +import { BoundingBox, Color, Matrix, Vector2 } from "@galacean/engine-math"; import { StaticInterfaceImplement } from "../../base/StaticInterfaceImplement"; import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManager"; -import { ISpriteLayout } from "../sprite/ISpriteLayout"; import { SpritePrimitive } from "../sprite/SpritePrimitive"; import { ISpriteAssembler } from "./ISpriteAssembler"; @@ -24,12 +23,15 @@ export class SimpleSpriteAssembler { static updatePositions( primitive: SpritePrimitive, chunkManager: PrimitiveChunkManager, - layout: ISpriteLayout, worldMatrix: Matrix, + width: number, + height: number, + pivot: Vector2, + flipX: boolean, + flipY: boolean, outBounds: BoundingBox ): void { const { sprite } = primitive; - const { width, height, pivot, flipX, flipY } = layout; const { x: pivotX, y: pivotY } = pivot; // Position to World const modelMatrix = SimpleSpriteAssembler._matrix; diff --git a/packages/core/src/2d/assembler/SlicedSpriteAssembler.ts b/packages/core/src/2d/assembler/SlicedSpriteAssembler.ts index 32e4e70ec4..24e07fd36c 100644 --- a/packages/core/src/2d/assembler/SlicedSpriteAssembler.ts +++ b/packages/core/src/2d/assembler/SlicedSpriteAssembler.ts @@ -1,7 +1,6 @@ -import { BoundingBox, Color, Matrix } from "@galacean/engine-math"; +import { BoundingBox, Color, Matrix, Vector2 } from "@galacean/engine-math"; import { StaticInterfaceImplement } from "../../base/StaticInterfaceImplement"; import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManager"; -import { ISpriteLayout } from "../sprite/ISpriteLayout"; import { SpritePrimitive } from "../sprite/SpritePrimitive"; import { ISpriteAssembler } from "./ISpriteAssembler"; @@ -29,13 +28,16 @@ export class SlicedSpriteAssembler { static updatePositions( primitive: SpritePrimitive, chunkManager: PrimitiveChunkManager, - layout: ISpriteLayout, worldMatrix: Matrix, - outBounds: BoundingBox + width: number, + height: number, + pivot: Vector2, + flipX: boolean, + flipY: boolean, + outBounds: BoundingBox, + referenceResolutionPerUnit: number = 1 ): void { const { sprite } = primitive; - const { width, height, pivot, flipX, flipY } = layout; - const referenceResolutionPerUnit = layout.referenceResolutionPerUnit ?? 1; const { border } = sprite; // Update local positions. const spritePositions = sprite._getPositions(); diff --git a/packages/core/src/2d/assembler/TiledSpriteAssembler.ts b/packages/core/src/2d/assembler/TiledSpriteAssembler.ts index 91a6da5481..ea35c4ebcb 100644 --- a/packages/core/src/2d/assembler/TiledSpriteAssembler.ts +++ b/packages/core/src/2d/assembler/TiledSpriteAssembler.ts @@ -1,10 +1,9 @@ -import { BoundingBox, Color, MathUtil, Matrix } from "@galacean/engine-math"; +import { BoundingBox, Color, MathUtil, Matrix, Vector2 } from "@galacean/engine-math"; import { Logger } from "../../base"; import { StaticInterfaceImplement } from "../../base/StaticInterfaceImplement"; import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManager"; import { DisorderedArray } from "../../utils/DisorderedArray"; import { SpriteTileMode } from "../enums/SpriteTileMode"; -import { ISpriteLayout } from "../sprite/ISpriteLayout"; import { Sprite } from "../sprite/Sprite"; import { SpritePrimitive } from "../sprite/SpritePrimitive"; import { ISpriteAssembler } from "./ISpriteAssembler"; @@ -37,14 +36,17 @@ export class TiledSpriteAssembler { static updatePositions( primitive: SpritePrimitive, chunkManager: PrimitiveChunkManager, - layout: ISpriteLayout, worldMatrix: Matrix, + width: number, + height: number, + pivot: Vector2, + flipX: boolean, + flipY: boolean, outBounds: BoundingBox, + referenceResolutionPerUnit: number = 1, tileMode?: SpriteTileMode, tiledAdaptiveThreshold?: number ): void { - const { width, height, pivot, flipX, flipY } = layout; - const referenceResolutionPerUnit = layout.referenceResolutionPerUnit ?? 1; // Calculate row and column const { _posRow: rPos, _posColumn: cPos, _uvRow: rUV, _uvColumn: cUV } = TiledSpriteAssembler; TiledSpriteAssembler.resetData( diff --git a/packages/core/src/2d/sprite/ISpriteLayout.ts b/packages/core/src/2d/sprite/ISpriteLayout.ts deleted file mode 100644 index 8a42016dd8..0000000000 --- a/packages/core/src/2d/sprite/ISpriteLayout.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Vector2 } from "@galacean/engine-math"; - -/** - * Geometry input for sprite assemblers. - */ -export interface ISpriteLayout { - readonly width: number; - readonly height: number; - readonly pivot: Vector2; - readonly flipX: boolean; - readonly flipY: boolean; - readonly referenceResolutionPerUnit: number | undefined; -} diff --git a/packages/core/src/2d/sprite/SpriteMask.ts b/packages/core/src/2d/sprite/SpriteMask.ts index 6189d39b8b..846296d049 100644 --- a/packages/core/src/2d/sprite/SpriteMask.ts +++ b/packages/core/src/2d/sprite/SpriteMask.ts @@ -226,15 +226,12 @@ export class SpriteMask extends Renderer { SimpleSpriteAssembler.updatePositions( this._spriteData, this._getChunkManager(), - { - width: this.width, - height: this.height, - pivot: sprite.pivot, - flipX: this._flipX, - flipY: this._flipY, - referenceResolutionPerUnit: undefined - }, this._transformEntity.transform.worldMatrix, + this.width, + this.height, + sprite.pivot, + this._flipX, + this._flipY, this._bounds ); } else { @@ -268,15 +265,12 @@ export class SpriteMask extends Renderer { SimpleSpriteAssembler.updatePositions( this._spriteData, this._getChunkManager(), - { - width: this.width, - height: this.height, - pivot: sprite.pivot, - flipX: this._flipX, - flipY: this._flipY, - referenceResolutionPerUnit: undefined - }, this._transformEntity.transform.worldMatrix, + this.width, + this.height, + sprite.pivot, + this._flipX, + this._flipY, this._bounds ); this._dirtyUpdateFlag &= ~RendererUpdateFlags.WorldVolume; diff --git a/packages/core/src/2d/sprite/SpriteRenderable.ts b/packages/core/src/2d/sprite/SpriteRenderable.ts index c0c33d82fb..e035963ff8 100644 --- a/packages/core/src/2d/sprite/SpriteRenderable.ts +++ b/packages/core/src/2d/sprite/SpriteRenderable.ts @@ -16,7 +16,6 @@ import { TiledSpriteAssembler } from "../assembler/TiledSpriteAssembler"; import { SpriteDrawMode } from "../enums/SpriteDrawMode"; import { SpriteModifyFlags } from "../enums/SpriteModifyFlags"; import { SpriteTileMode } from "../enums/SpriteTileMode"; -import { ISpriteLayout } from "./ISpriteLayout"; import { Sprite } from "./Sprite"; import { SpritePrimitive } from "./SpritePrimitive"; @@ -75,22 +74,10 @@ export interface ISpriteRenderable { * All host-specific behavior is accessed through abstract methods, composition objects, and hooks. * The mixin NEVER touches host private fields directly. */ -type MutableSpriteLayout = { -readonly [K in keyof ISpriteLayout]: ISpriteLayout[K] }; - export function SpriteRenderable( Base: T ): (abstract new (...args: any[]) => ISpriteRenderable) & T { abstract class SpriteRenderableHost extends Base { - /** Static cached layout object for assembler calls. */ - private static _layoutCache: MutableSpriteLayout = { - width: 0, - height: 0, - pivot: null, - flipX: false, - flipY: false, - referenceResolutionPerUnit: undefined - }; - /** @internal */ @ignoreClone _spriteData: SpritePrimitive; @@ -290,9 +277,14 @@ export function SpriteRenderable( this._assembler.updatePositions( this._spriteData, this._getChunkManager(), - this._fillLayout(), this._transformEntity.transform.worldMatrix, + this._getSpriteWidth(), + this._getSpriteHeight(), + this._getSpritePivot(), + this._getSpriteFlipX(), + this._getSpriteFlipY(), this._bounds, + this._getReferenceResolutionPerUnit(), this._tileMode, this._tiledAdaptiveThreshold ); @@ -330,9 +322,14 @@ export function SpriteRenderable( this._assembler.updatePositions( this._spriteData, this._getChunkManager(), - this._fillLayout(), this._transformEntity.transform.worldMatrix, + this._getSpriteWidth(), + this._getSpriteHeight(), + this._getSpritePivot(), + this._getSpriteFlipX(), + this._getSpriteFlipY(), this._bounds, + this._getReferenceResolutionPerUnit(), this._tileMode, this._tiledAdaptiveThreshold ); @@ -363,19 +360,6 @@ export function SpriteRenderable( super._onDestroy(); } - // ===== Private: fill layout cache ===== - - private _fillLayout(): ISpriteLayout { - const layout = SpriteRenderableHost._layoutCache; - layout.width = this._getSpriteWidth(); - layout.height = this._getSpriteHeight(); - layout.pivot = this._getSpritePivot(); - layout.flipX = this._getSpriteFlipX(); - layout.flipY = this._getSpriteFlipY(); - layout.referenceResolutionPerUnit = this._getReferenceResolutionPerUnit(); - return layout; - } - // ===== Wiring: sprite change dispatch ===== /** diff --git a/packages/core/src/2d/sprite/index.ts b/packages/core/src/2d/sprite/index.ts index 056166c6e1..51e7e4fbe4 100644 --- a/packages/core/src/2d/sprite/index.ts +++ b/packages/core/src/2d/sprite/index.ts @@ -1,4 +1,3 @@ -export type { ISpriteLayout } from "./ISpriteLayout"; export { Sprite } from "./Sprite"; export { SpritePrimitive } from "./SpritePrimitive"; export type { ISpritePrimitiveOwner } from "./SpritePrimitive"; diff --git a/tests/src/core/SpriteMask.test.ts b/tests/src/core/SpriteMask.test.ts index 0e671e6869..d35012ffe0 100644 --- a/tests/src/core/SpriteMask.test.ts +++ b/tests/src/core/SpriteMask.test.ts @@ -176,7 +176,7 @@ describe("SpriteMask", async () => { // @ts-ignore spriteMask._render(context); // @ts-ignore - const subChunk = spriteMask._subChunk; + const subChunk = spriteMask._spriteData.subChunk; const vertices = subChunk.chunk.vertices; const positions: Array = []; const uvs: Array = []; diff --git a/tests/src/core/SpriteRenderer.test.ts b/tests/src/core/SpriteRenderer.test.ts index a3252efa62..e7ea16c73e 100644 --- a/tests/src/core/SpriteRenderer.test.ts +++ b/tests/src/core/SpriteRenderer.test.ts @@ -189,7 +189,7 @@ describe("SpriteRenderer", async () => { spriteRenderer.width = 4; spriteRenderer.height = 5; // @ts-ignore - const subChunk = spriteRenderer._subChunk; + const subChunk = spriteRenderer._spriteData.subChunk; const vertices = subChunk.chunk.vertices; const positions: Array = []; const uvs: Array = []; @@ -261,7 +261,7 @@ describe("SpriteRenderer", async () => { spriteRenderer.sprite = sprite; spriteRenderer.drawMode = SpriteDrawMode.Sliced; // @ts-ignore - const subChunk = spriteRenderer._subChunk; + const subChunk = spriteRenderer._spriteData.subChunk; const vertices = subChunk.chunk.vertices; const positions: Array = []; const uvs: Array = []; @@ -365,7 +365,7 @@ describe("SpriteRenderer", async () => { spriteRenderer.sprite = sprite; spriteRenderer.drawMode = SpriteDrawMode.Tiled; // @ts-ignore - const subChunk = spriteRenderer._subChunk; + const subChunk = spriteRenderer._spriteData.subChunk; const vertices = subChunk.chunk.vertices; const positions: Array = []; const uvs: Array = []; @@ -1521,7 +1521,7 @@ describe("SpriteRenderer", async () => { // @ts-ignore expect(spriteRenderer._assembler).to.eq(null); // @ts-ignore - expect(spriteRenderer._subChunk).to.eq(null); + expect(spriteRenderer._spriteData.subChunk).to.eq(null); }); it("_render", () => { @@ -1532,7 +1532,7 @@ describe("SpriteRenderer", async () => { // @ts-ignore spriteRenderer._render(context); // @ts-ignore - const subChunk = spriteRenderer._subChunk; + const subChunk = spriteRenderer._spriteData.subChunk; const vertices = subChunk.chunk.vertices; const positions: Array = []; const uvs: Array = []; From 42e5098cf695c7a5488636f57fc269c47d9f84d3 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Mon, 23 Mar 2026 15:38:52 +0800 Subject: [PATCH 5/9] feat: add ui mask --- packages/core/src/2d/sprite/SpriteMask.ts | 282 +++++++----------- .../core/src/2d/sprite/SpriteMaskUtils.ts | 136 +++++++++ .../core/src/2d/sprite/SpriteRenderable.ts | 37 +++ packages/core/src/2d/sprite/SpriteRenderer.ts | 26 -- packages/core/src/2d/sprite/index.ts | 1 + packages/core/src/2d/text/TextRenderable.ts | 39 +++ packages/core/src/2d/text/TextRenderer.ts | 26 -- packages/core/src/BasicResources.ts | 45 +++ .../core/src/RenderPipeline/BatchUtils.ts | 34 +++ .../core/src/RenderPipeline/MaskManager.ts | 63 ++++ .../core/src/RenderPipeline/RenderQueue.ts | 16 +- .../src/RenderPipeline/SubRenderElement.ts | 5 + packages/core/src/Renderer.ts | 7 - .../core/src/shaderlib/extra/text.fs.glsl | 33 +- .../core/src/shaderlib/extra/text.vs.glsl | 3 + packages/ui/src/component/UICanvas.ts | 44 ++- packages/ui/src/component/UIRenderer.ts | 189 +++++++++++- packages/ui/src/component/advanced/Image.ts | 7 + packages/ui/src/component/advanced/Mask.ts | 139 +++++++++ .../ui/src/component/advanced/RectMask2D.ts | 157 ++++++++++ packages/ui/src/component/advanced/Text.ts | 17 +- packages/ui/src/component/index.ts | 2 + packages/ui/src/shader/uiDefault.fs.glsl | 30 ++ packages/ui/src/shader/uiDefault.vs.glsl | 3 + tests/src/core/SpriteMask.test.ts | 39 +-- 25 files changed, 1099 insertions(+), 281 deletions(-) create mode 100644 packages/core/src/2d/sprite/SpriteMaskUtils.ts create mode 100644 packages/ui/src/component/advanced/Mask.ts create mode 100644 packages/ui/src/component/advanced/RectMask2D.ts diff --git a/packages/core/src/2d/sprite/SpriteMask.ts b/packages/core/src/2d/sprite/SpriteMask.ts index 846296d049..83744b6b0e 100644 --- a/packages/core/src/2d/sprite/SpriteMask.ts +++ b/packages/core/src/2d/sprite/SpriteMask.ts @@ -1,39 +1,38 @@ -import { BoundingBox } from "@galacean/engine-math"; +import { Color, Vector2, Vector3 } from "@galacean/engine-math"; import { Entity } from "../../Entity"; import { RenderQueueFlags } from "../../RenderPipeline/BasicRenderPipeline"; import { BatchUtils } from "../../RenderPipeline/BatchUtils"; import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManager"; import { RenderContext } from "../../RenderPipeline/RenderContext"; import { RenderElement } from "../../RenderPipeline/RenderElement"; +import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; import { SubRenderElement } from "../../RenderPipeline/SubRenderElement"; import { Renderer, RendererUpdateFlags } from "../../Renderer"; import { assignmentClone, ignoreClone } from "../../clone/CloneManager"; import { SpriteMaskLayer } from "../../enums/SpriteMaskLayer"; +import { Material } from "../../material"; import { ShaderProperty } from "../../shader/ShaderProperty"; -import { SimpleSpriteAssembler } from "../assembler/SimpleSpriteAssembler"; -import { SpriteModifyFlags } from "../enums/SpriteModifyFlags"; -import { Sprite } from "./Sprite"; -import { SpritePrimitive } from "./SpritePrimitive"; +import { Texture2D } from "../../texture"; +import { SpriteRenderable } from "./SpriteRenderable"; +import { SpriteMaskUtils } from "./SpriteMaskUtils"; /** * A component for masking Sprites. */ -export class SpriteMask extends Renderer { +export class SpriteMask extends SpriteRenderable(Renderer) { /** @internal */ static _textureProperty: ShaderProperty = ShaderProperty.getByName("renderer_MaskTexture"); /** @internal */ static _alphaCutoffProperty: ShaderProperty = ShaderProperty.getByName("renderer_MaskAlphaCutoff"); + private static _defaultColor: Color = new Color(1, 1, 1, 1); + /** The mask layers the sprite mask influence to. */ @assignmentClone influenceLayers: SpriteMaskLayer = SpriteMaskLayer.Everything; /** @internal */ @ignoreClone _renderElement: RenderElement; - - /** @internal */ - @ignoreClone - _spriteData: SpritePrimitive; /** @internal */ @ignoreClone _maskIndex: number = -1; @@ -50,6 +49,8 @@ export class SpriteMask extends Renderer { private _flipX: boolean = false; @assignmentClone private _flipY: boolean = false; + @ignoreClone + private _autoSizeDirty: boolean = true; @assignmentClone private _alphaCutoff: number = 0.5; @@ -64,10 +65,11 @@ export class SpriteMask extends Renderer { get width(): number { if (this._customWidth !== undefined) { return this._customWidth; - } else { - this._dirtyUpdateFlag & SpriteMaskUpdateFlags.AutomaticSize && this._calDefaultSize(); - return this._automaticWidth; } + if (this._autoSizeDirty) { + this._calDefaultSize(); + } + return this._automaticWidth; } set width(value: number) { @@ -87,10 +89,11 @@ export class SpriteMask extends Renderer { get height(): number { if (this._customHeight !== undefined) { return this._customHeight; - } else { - this._dirtyUpdateFlag & SpriteMaskUpdateFlags.AutomaticSize && this._calDefaultSize(); - return this._automaticHeight; } + if (this._autoSizeDirty) { + this._calDefaultSize(); + } + return this._automaticHeight; } set height(value: number) { @@ -128,18 +131,6 @@ export class SpriteMask extends Renderer { } } - /** - * The Sprite to render. - */ - get sprite(): Sprite { - return this._spriteData.sprite; - } - - set sprite(value: Sprite | null) { - this._spriteData.sprite = value; - this._dirtyUpdateFlag |= SpriteMaskUpdateFlags.All; - } - /** * The minimum alpha value used by the mask to select the area of influence defined over the mask's sprite. Value between 0 and 1. */ @@ -154,47 +145,90 @@ export class SpriteMask extends Renderer { } } + /** + * @internal + */ + get color(): Color { + return SpriteMask._defaultColor; + } + /** * @internal */ constructor(entity: Entity) { super(entity); - this._spriteData = new SpritePrimitive(this as any, SpriteMask._textureProperty, this._onSpriteChange.bind(this)); - SimpleSpriteAssembler.resetData(this._spriteData, this._getChunkManager()); - this.setMaterial(this._engine._basicResources.spriteMaskDefaultMaterial); + this._initSpriteRenderable(SpriteMask._textureProperty); this.shaderData.setFloat(SpriteMask._alphaCutoffProperty, this._alphaCutoff); this._renderElement = new RenderElement(); this._renderElement.addSubRenderElement(new SubRenderElement()); } - /** - * @internal - */ - override _updateTransformShaderData(context: RenderContext, onlyMVP: boolean, batched: boolean): void { - //@todo: Always update world positions to buffer, should opt - super._updateTransformShaderData(context, onlyMVP, true); + // ===== SpriteRenderable abstract implementations ===== + + /** @internal */ + override _getChunkManager(): PrimitiveChunkManager { + return this.engine._batcherManager.primitiveChunkManagerMask; } - /** - * @internal - */ - override _cloneTo(target: SpriteMask): void { - super._cloneTo(target); - this._spriteData.cloneTo(target._spriteData); + /** @internal */ + override _getDefaultSpriteMaterial(): Material { + return this._engine._basicResources.spriteMaskDefaultMaterial; } - /** - * @internal - */ - override _canBatch(elementA: SubRenderElement, elementB: SubRenderElement): boolean { - return BatchUtils.canBatchSpriteMask(elementA, elementB); + /** @internal */ + override _submitSpriteRenderElement( + context: RenderContext, + material: Material, + subChunk: SubPrimitiveChunk, + texture: Texture2D + ): void { + const renderElement = this._renderElement; + const subRenderElement = renderElement.subRenderElements[0]; + renderElement.set(this.priority, this._distanceForSort); + subRenderElement.set(this, material, subChunk.chunk.primitive, subChunk.subMesh, texture, subChunk); + subRenderElement.shaderPasses = material.shader.subShaders[0].passes; + subRenderElement.renderQueueFlags = RenderQueueFlags.All; + renderElement.addSubRenderElement(subRenderElement); } - /** - * @internal - */ - override _batch(elementA: SubRenderElement, elementB?: SubRenderElement): void { - BatchUtils.batchFor2D(elementA, elementB); + /** @internal */ + override _getSpriteWidth(): number { + return this.width; + } + + /** @internal */ + override _getSpriteHeight(): number { + return this.height; + } + + /** @internal */ + override _getSpriteFlipX(): boolean { + return this._flipX; + } + + /** @internal */ + override _getSpriteFlipY(): boolean { + return this._flipY; + } + + /** @internal */ + override _onSpriteSizeChanged(): void { + this._autoSizeDirty = true; + if (this._customWidth === undefined || this._customHeight === undefined) { + this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; + } + } + + /** @internal */ + override _onSpritePivotChanged(): void { + this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; + } + + // ===== Mask-specific overrides ===== + + /** @internal */ + override _canBatch(elementA: SubRenderElement, elementB: SubRenderElement): boolean { + return BatchUtils.canBatchSpriteMask(elementA, elementB); } /** @@ -216,145 +250,35 @@ export class SpriteMask extends Renderer { /** * @internal */ - _getChunkManager(): PrimitiveChunkManager { - return this.engine._batcherManager.primitiveChunkManagerMask; - } - - protected override _updateBounds(worldBounds: BoundingBox): void { - const sprite = this._spriteData.sprite; - if (sprite) { - SimpleSpriteAssembler.updatePositions( - this._spriteData, - this._getChunkManager(), - this._transformEntity.transform.worldMatrix, - this.width, - this.height, - sprite.pivot, - this._flipX, - this._flipY, - this._bounds - ); - } else { - const { worldPosition } = this._transformEntity.transform; - worldBounds.min.copyFrom(worldPosition); - worldBounds.max.copyFrom(worldPosition); - } + _containsWorldPoint(worldPoint: Vector3): boolean { + const sprite = this.sprite; + if (!sprite) return false; + return SpriteMaskUtils.containsWorldPoint( + worldPoint, + sprite, + this._transformEntity.transform.worldMatrix, + this.width, + this.height, + sprite.pivot, + this._getSpriteFlipX(), + this._getSpriteFlipY(), + this.alphaCutoff + ); } - /** - * @inheritdoc - */ - protected override _render(context: RenderContext): void { - const sprite = this._spriteData.sprite; - if (!sprite?.texture || !this.width || !this.height) { - return; - } - - let material = this.getMaterial(); - if (!material) { - return; - } - const { _engine: engine } = this; - // @todo: This question needs to be raised rather than hidden. - if (material.destroyed) { - material = engine._basicResources.spriteMaskDefaultMaterial; - } - - // Update position - if (this._dirtyUpdateFlag & RendererUpdateFlags.WorldVolume) { - SimpleSpriteAssembler.updatePositions( - this._spriteData, - this._getChunkManager(), - this._transformEntity.transform.worldMatrix, - this.width, - this.height, - sprite.pivot, - this._flipX, - this._flipY, - this._bounds - ); - this._dirtyUpdateFlag &= ~RendererUpdateFlags.WorldVolume; - } - - // Update uv - if (this._dirtyUpdateFlag & SpriteMaskUpdateFlags.UV) { - SimpleSpriteAssembler.updateUVs(this._spriteData); - this._dirtyUpdateFlag &= ~SpriteMaskUpdateFlags.UV; - } - - const renderElement = this._renderElement; - const subRenderElement = renderElement.subRenderElements[0]; - renderElement.set(this.priority, this._distanceForSort); - - const subChunk = this._spriteData.subChunk; - subRenderElement.set(this, material, subChunk.chunk.primitive, subChunk.subMesh, sprite.texture, subChunk); - subRenderElement.shaderPasses = material.shader.subShaders[0].passes; - subRenderElement.renderQueueFlags = RenderQueueFlags.All; - renderElement.addSubRenderElement(subRenderElement); - } - - /** - * @inheritdoc - */ protected override _onDestroy(): void { - this._spriteData.destroy(this._getChunkManager()); - super._onDestroy(); - this._renderElement = null; } private _calDefaultSize(): void { - const sprite = this._spriteData.sprite; + const sprite = this.sprite; if (sprite) { this._automaticWidth = sprite.width; this._automaticHeight = sprite.height; } else { this._automaticWidth = this._automaticHeight = 0; } - this._dirtyUpdateFlag &= ~SpriteMaskUpdateFlags.AutomaticSize; + this._autoSizeDirty = false; } - - @ignoreClone - private _onSpriteChange(type: SpriteModifyFlags | null): void { - if (type === null) { - // Sprite instance replaced - this._dirtyUpdateFlag |= SpriteMaskUpdateFlags.All; - return; - } - switch (type) { - case SpriteModifyFlags.size: - this._dirtyUpdateFlag |= SpriteMaskUpdateFlags.AutomaticSize; - if (this._customWidth === undefined || this._customHeight === undefined) { - this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; - } - break; - case SpriteModifyFlags.region: - case SpriteModifyFlags.atlasRegionOffset: - this._dirtyUpdateFlag |= SpriteMaskUpdateFlags.WorldVolumeAndUV; - break; - case SpriteModifyFlags.atlasRegion: - this._dirtyUpdateFlag |= SpriteMaskUpdateFlags.UV; - break; - case SpriteModifyFlags.pivot: - this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; - break; - default: - break; - } - } -} - -/** - * @remarks Extends `RendererUpdateFlags`. - */ -enum SpriteMaskUpdateFlags { - /** UV. */ - UV = 0x2, - /** Automatic Size. */ - AutomaticSize = 0x4, - /** WorldVolume and UV. */ - WorldVolumeAndUV = 0x3, - /** All. */ - All = 0x7 } diff --git a/packages/core/src/2d/sprite/SpriteMaskUtils.ts b/packages/core/src/2d/sprite/SpriteMaskUtils.ts new file mode 100644 index 0000000000..b7033ee800 --- /dev/null +++ b/packages/core/src/2d/sprite/SpriteMaskUtils.ts @@ -0,0 +1,136 @@ +import { Matrix, Vector2, Vector3 } from "@galacean/engine-math"; +import { Texture2D, TextureFormat } from "../../texture"; +import { Sprite } from "./Sprite"; + +/** + * Internal helpers for sprite mask hit testing. + * @internal + */ +export class SpriteMaskUtils { + private static _tempMat: Matrix = new Matrix(); + private static _tempVec3: Vector3 = new Vector3(); + private static _u8Buffer1 = new Uint8Array(1); + private static _u8Buffer2 = new Uint8Array(2); + private static _u8Buffer4 = new Uint8Array(4); + private static _u16Buffer1 = new Uint16Array(1); + private static _u16Buffer4 = new Uint16Array(4); + private static _f32Buffer4 = new Float32Array(4); + private static _u32Buffer4 = new Uint32Array(4); + + static containsWorldPoint( + worldPoint: Vector3, + sprite: Sprite | null, + worldMatrix: Matrix, + width: number, + height: number, + pivot: Vector2, + flipX: boolean, + flipY: boolean, + alphaCutoff: number = 0 + ): boolean { + if (!sprite || !width || !height) { + return false; + } + + const worldMatrixInv = SpriteMaskUtils._tempMat; + Matrix.invert(worldMatrix, worldMatrixInv); + const localPosition = SpriteMaskUtils._tempVec3; + Vector3.transformCoordinate(worldPoint, worldMatrixInv, localPosition); + + const sx = flipX ? -width : width; + const sy = flipY ? -height : height; + if (!sx || !sy) { + return false; + } + + const spriteX = localPosition.x / sx + pivot.x; + const spriteY = localPosition.y / sy + pivot.y; + const spritePositions = sprite._getPositions(); + const { x: left, y: bottom } = spritePositions[0]; + const { x: right, y: top } = spritePositions[3]; + if (!(spriteX >= left && spriteX <= right && spriteY >= bottom && spriteY <= top)) { + return false; + } + + if (alphaCutoff <= 0) { + return true; + } + + const texture = sprite.texture; + if (!texture) { + return false; + } + + const spriteUVs = sprite._getUVs(); + const leftU = spriteUVs[0].x; + const bottomV = spriteUVs[0].y; + const rightU = spriteUVs[3].x; + const topV = spriteUVs[3].y; + const positionWidth = right - left; + const positionHeight = top - bottom; + if (!positionWidth || !positionHeight) { + return false; + } + + const tx = (spriteX - left) / positionWidth; + const ty = (spriteY - bottom) / positionHeight; + const u = leftU + (rightU - leftU) * tx; + const v = bottomV + (topV - bottomV) * ty; + const x = Math.min(Math.max(Math.floor(u * texture.width), 0), texture.width - 1); + const y = Math.min(Math.max(Math.floor(v * texture.height), 0), texture.height - 1); + return SpriteMaskUtils._sampleTextureAlpha(texture, x, y) >= alphaCutoff; + } + + private static _sampleTextureAlpha(texture: Texture2D, x: number, y: number): number { + try { + switch (texture.format) { + case TextureFormat.R8G8B8A8: { + const buffer = SpriteMaskUtils._u8Buffer4; + texture.getPixelBuffer(x, y, 1, 1, buffer); + return buffer[3] / 255; + } + case TextureFormat.R4G4B4A4: { + const buffer = SpriteMaskUtils._u16Buffer1; + texture.getPixelBuffer(x, y, 1, 1, buffer); + return (buffer[0] & 0xf) / 15; + } + case TextureFormat.R5G5B5A1: { + const buffer = SpriteMaskUtils._u16Buffer1; + texture.getPixelBuffer(x, y, 1, 1, buffer); + return buffer[0] & 0x1; + } + case TextureFormat.Alpha8: + case TextureFormat.R8: { + const buffer = SpriteMaskUtils._u8Buffer1; + texture.getPixelBuffer(x, y, 1, 1, buffer); + return buffer[0] / 255; + } + case TextureFormat.LuminanceAlpha: + case TextureFormat.R8G8: { + const buffer = SpriteMaskUtils._u8Buffer2; + texture.getPixelBuffer(x, y, 1, 1, buffer); + return buffer[1] / 255; + } + case TextureFormat.R16G16B16A16: { + const buffer = SpriteMaskUtils._u16Buffer4; + texture.getPixelBuffer(x, y, 1, 1, buffer); + return buffer[3] / 65535; + } + case TextureFormat.R32G32B32A32: { + const buffer = SpriteMaskUtils._f32Buffer4; + texture.getPixelBuffer(x, y, 1, 1, buffer); + return buffer[3]; + } + case TextureFormat.R32G32B32A32_UInt: { + const buffer = SpriteMaskUtils._u32Buffer4; + texture.getPixelBuffer(x, y, 1, 1, buffer); + return buffer[3] / 4294967295; + } + default: + return 1; + } + } catch { + return 1; + } + } +} diff --git a/packages/core/src/2d/sprite/SpriteRenderable.ts b/packages/core/src/2d/sprite/SpriteRenderable.ts index e035963ff8..702bc6a559 100644 --- a/packages/core/src/2d/sprite/SpriteRenderable.ts +++ b/packages/core/src/2d/sprite/SpriteRenderable.ts @@ -6,9 +6,11 @@ import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; import { SubRenderElement } from "../../RenderPipeline/SubRenderElement"; import { Renderer, RendererUpdateFlags } from "../../Renderer"; import { assignmentClone, ignoreClone } from "../../clone/CloneManager"; +import { SpriteMaskLayer } from "../../enums/SpriteMaskLayer"; import { Material } from "../../material"; import { ShaderProperty } from "../../shader/ShaderProperty"; import { Texture2D } from "../../texture"; +import { SpriteMaskInteraction } from "../enums/SpriteMaskInteraction"; import { ISpriteAssembler } from "../assembler/ISpriteAssembler"; import { SimpleSpriteAssembler } from "../assembler/SimpleSpriteAssembler"; import { SlicedSpriteAssembler } from "../assembler/SlicedSpriteAssembler"; @@ -46,6 +48,10 @@ export interface ISpriteRenderable { drawMode: SpriteDrawMode; tileMode: SpriteTileMode; tiledAdaptiveThreshold: number; + maskInteraction: SpriteMaskInteraction; + maskLayer: SpriteMaskLayer; + _maskInteraction: SpriteMaskInteraction; + _maskLayer: SpriteMaskLayer; _spriteData: SpritePrimitive; _getChunkManager(): PrimitiveChunkManager; _getDefaultSpriteMaterial(): Material; @@ -82,6 +88,13 @@ export function SpriteRenderable( @ignoreClone _spriteData: SpritePrimitive; + /** @internal */ + @assignmentClone + _maskInteraction: SpriteMaskInteraction = SpriteMaskInteraction.None; + /** @internal */ + @assignmentClone + _maskLayer: SpriteMaskLayer = SpriteMaskLayer.Layer0; + @ignoreClone private _drawMode: SpriteDrawMode; @ignoreClone @@ -151,6 +164,30 @@ export function SpriteRenderable( // ===== Public API (forwarding) ===== + /** + * The mask layer the renderer belongs to. + */ + get maskLayer(): SpriteMaskLayer { + return this._maskLayer; + } + + set maskLayer(value: SpriteMaskLayer) { + this._maskLayer = value; + } + + /** + * Interacts with the masks. + */ + get maskInteraction(): SpriteMaskInteraction { + return this._maskInteraction; + } + + set maskInteraction(value: SpriteMaskInteraction) { + if (this._maskInteraction !== value) { + this._maskInteraction = value; + } + } + /** * The Sprite to render. */ diff --git a/packages/core/src/2d/sprite/SpriteRenderer.ts b/packages/core/src/2d/sprite/SpriteRenderer.ts index 20f7cec4d2..9e5fe63858 100644 --- a/packages/core/src/2d/sprite/SpriteRenderer.ts +++ b/packages/core/src/2d/sprite/SpriteRenderer.ts @@ -5,12 +5,10 @@ import { RenderContext } from "../../RenderPipeline/RenderContext"; import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; import { Renderer, RendererUpdateFlags } from "../../Renderer"; import { deepClone, ignoreClone } from "../../clone/CloneManager"; -import { SpriteMaskLayer } from "../../enums/SpriteMaskLayer"; import { Material } from "../../material"; import { ShaderProperty } from "../../shader/ShaderProperty"; import { Texture2D } from "../../texture"; import { SpriteDrawMode } from "../enums/SpriteDrawMode"; -import { SpriteMaskInteraction } from "../enums/SpriteMaskInteraction"; import { SpriteRenderable, SpriteRenderableFlags } from "./SpriteRenderable"; /** @@ -133,30 +131,6 @@ export class SpriteRenderer extends SpriteRenderable(Renderer) { } } - /** - * The mask layer the sprite renderer belongs to. - */ - get maskLayer(): SpriteMaskLayer { - return this._maskLayer; - } - - set maskLayer(value: SpriteMaskLayer) { - this._maskLayer = value; - } - - /** - * Interacts with the masks. - */ - get maskInteraction(): SpriteMaskInteraction { - return this._maskInteraction; - } - - set maskInteraction(value: SpriteMaskInteraction) { - if (this._maskInteraction !== value) { - this._maskInteraction = value; - } - } - /** * @internal */ diff --git a/packages/core/src/2d/sprite/index.ts b/packages/core/src/2d/sprite/index.ts index 51e7e4fbe4..c3265f07f1 100644 --- a/packages/core/src/2d/sprite/index.ts +++ b/packages/core/src/2d/sprite/index.ts @@ -4,4 +4,5 @@ export type { ISpritePrimitiveOwner } from "./SpritePrimitive"; export type { ISpriteRenderable } from "./SpriteRenderable"; export { SpriteRenderable, SpriteRenderableFlags } from "./SpriteRenderable"; export { SpriteMask } from "./SpriteMask"; +export { SpriteMaskUtils } from "./SpriteMaskUtils"; export { SpriteRenderer } from "./SpriteRenderer"; diff --git a/packages/core/src/2d/text/TextRenderable.ts b/packages/core/src/2d/text/TextRenderable.ts index fb99f8d1f6..cb6e6e689c 100644 --- a/packages/core/src/2d/text/TextRenderable.ts +++ b/packages/core/src/2d/text/TextRenderable.ts @@ -7,9 +7,11 @@ import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; import { SubRenderElement } from "../../RenderPipeline/SubRenderElement"; import { Renderer, RendererUpdateFlags } from "../../Renderer"; import { assignmentClone, ignoreClone } from "../../clone/CloneManager"; +import { SpriteMaskLayer } from "../../enums/SpriteMaskLayer"; import { Material } from "../../material"; import { ShaderProperty } from "../../shader"; import { Texture2D } from "../../texture"; +import { SpriteMaskInteraction } from "../enums/SpriteMaskInteraction"; import { FontStyle } from "../enums/FontStyle"; import { TextHorizontalAlignment, TextVerticalAlignment } from "../enums/TextAlignment"; import { OverflowMode } from "../enums/TextOverflow"; @@ -56,6 +58,10 @@ export interface ITextRenderable { verticalAlignment: TextVerticalAlignment; enableWrapping: boolean; overflowMode: OverflowMode; + maskInteraction: SpriteMaskInteraction; + maskLayer: SpriteMaskLayer; + _maskInteraction: SpriteMaskInteraction; + _maskLayer: SpriteMaskLayer; _subFont: SubFont; _getChunkManager(): PrimitiveChunkManager; _getSubFont(): SubFont; @@ -88,6 +94,13 @@ export function TextRenderable( private static _worldPositions = [new Vector3(), new Vector3(), new Vector3(), new Vector3()]; private static _charRenderInfos: CharRenderInfo[] = []; + /** @internal */ + @assignmentClone + _maskInteraction: SpriteMaskInteraction = SpriteMaskInteraction.None; + /** @internal */ + @assignmentClone + _maskLayer: SpriteMaskLayer = SpriteMaskLayer.Layer0; + @ignoreClone private _textChunks = Array(); /** @internal */ @@ -153,6 +166,32 @@ export function TextRenderable( return undefined; } + // ===== Mask properties ===== + + /** + * The mask layer the renderer belongs to. + */ + get maskLayer(): SpriteMaskLayer { + return this._maskLayer; + } + + set maskLayer(value: SpriteMaskLayer) { + this._maskLayer = value; + } + + /** + * Interacts with the masks. + */ + get maskInteraction(): SpriteMaskInteraction { + return this._maskInteraction; + } + + set maskInteraction(value: SpriteMaskInteraction) { + if (this._maskInteraction !== value) { + this._maskInteraction = value; + } + } + // ===== Text properties ===== get text(): string { diff --git a/packages/core/src/2d/text/TextRenderer.ts b/packages/core/src/2d/text/TextRenderer.ts index 8f104b8344..a7c4482985 100644 --- a/packages/core/src/2d/text/TextRenderer.ts +++ b/packages/core/src/2d/text/TextRenderer.ts @@ -4,10 +4,8 @@ import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManage import { RenderContext } from "../../RenderPipeline/RenderContext"; import { Renderer } from "../../Renderer"; import { deepClone, ignoreClone } from "../../clone/CloneManager"; -import { SpriteMaskLayer } from "../../enums/SpriteMaskLayer"; import { ShaderData } from "../../shader"; import { ShaderDataGroup } from "../../shader/enums/ShaderDataGroup"; -import { SpriteMaskInteraction } from "../enums/SpriteMaskInteraction"; import { Material } from "../../material"; import { TextChunk, TextRenderable, TextRenderableFlags } from "./TextRenderable"; @@ -62,30 +60,6 @@ export class TextRenderer extends TextRenderable(Renderer) { } } - /** - * Interacts with the masks. - */ - get maskInteraction(): SpriteMaskInteraction { - return this._maskInteraction; - } - - set maskInteraction(value: SpriteMaskInteraction) { - if (this._maskInteraction !== value) { - this._maskInteraction = value; - } - } - - /** - * The mask layer the sprite renderer belongs to. - */ - get maskLayer(): SpriteMaskLayer { - return this._maskLayer; - } - - set maskLayer(value: SpriteMaskLayer) { - this._maskLayer = value; - } - constructor(entity: Entity) { super(entity); this._initTextRenderable(); diff --git a/packages/core/src/BasicResources.ts b/packages/core/src/BasicResources.ts index 264cf2ed95..dae8bd2a70 100644 --- a/packages/core/src/BasicResources.ts +++ b/packages/core/src/BasicResources.ts @@ -33,6 +33,8 @@ export class BasicResources { private static _maskReadOutsideRenderStates: RenderStateElementMap = null; private static _maskWriteIncrementRenderStates: RenderStateElementMap = null; private static _maskWriteDecrementRenderStates: RenderStateElementMap = null; + private static _uiStencilWriteStates: RenderStateElementMap = null; + private static _uiStencilTestStatesCache: Map = new Map(); static getMaskInteractionRenderStates(maskInteraction: SpriteMaskInteraction): RenderStateElementMap { const visibleInsideMask = maskInteraction === SpriteMaskInteraction.VisibleInsideMask; @@ -102,6 +104,49 @@ export class BasicResources { return renderStates; } + /** + * Get stencil write states for UI hierarchy-based mask (increment, no color write). + */ + static getUIStencilWriteStates(): RenderStateElementMap { + let renderStates = BasicResources._uiStencilWriteStates; + if (renderStates) { + return renderStates; + } + BasicResources._uiStencilWriteStates = renderStates = {}; + renderStates[RenderStateElementKey.StencilStateEnabled] = true; + renderStates[RenderStateElementKey.StencilStatePassOperationFront] = StencilOperation.IncrementSaturate; + renderStates[RenderStateElementKey.StencilStatePassOperationBack] = StencilOperation.IncrementSaturate; + renderStates[RenderStateElementKey.StencilStateCompareFunctionFront] = CompareFunction.Always; + renderStates[RenderStateElementKey.StencilStateCompareFunctionBack] = CompareFunction.Always; + renderStates[RenderStateElementKey.StencilStateFailOperationFront] = StencilOperation.Keep; + renderStates[RenderStateElementKey.StencilStateFailOperationBack] = StencilOperation.Keep; + renderStates[RenderStateElementKey.StencilStateZFailOperationFront] = StencilOperation.Keep; + renderStates[RenderStateElementKey.StencilStateZFailOperationBack] = StencilOperation.Keep; + renderStates[RenderStateElementKey.BlendStateColorWriteMask0] = ColorWriteMask.None; + renderStates[RenderStateElementKey.DepthStateEnabled] = false; + renderStates[RenderStateElementKey.RasterStateCullMode] = CullMode.Off; + return renderStates; + } + + /** + * Get stencil test states for UI hierarchy-based mask (read stencil, ref = depth, LessEqual). + */ + static getUIStencilTestStates(depth: number): RenderStateElementMap { + const cache = BasicResources._uiStencilTestStatesCache; + let renderStates = cache.get(depth); + if (renderStates) { + return renderStates; + } + renderStates = {}; + renderStates[RenderStateElementKey.StencilStateEnabled] = true; + renderStates[RenderStateElementKey.StencilStateWriteMask] = 0x00; + renderStates[RenderStateElementKey.StencilStateReferenceValue] = depth; + renderStates[RenderStateElementKey.StencilStateCompareFunctionFront] = CompareFunction.LessEqual; + renderStates[RenderStateElementKey.StencilStateCompareFunctionBack] = CompareFunction.LessEqual; + cache.set(depth, renderStates); + return renderStates; + } + /** * Use triangle to blit texture, ref: https://michaldrobot.com/2014/04/01/gcn-execution-patterns-in-full-screen-passes/ . */ diff --git a/packages/core/src/RenderPipeline/BatchUtils.ts b/packages/core/src/RenderPipeline/BatchUtils.ts index 387c951f93..1b250d407f 100644 --- a/packages/core/src/RenderPipeline/BatchUtils.ts +++ b/packages/core/src/RenderPipeline/BatchUtils.ts @@ -16,9 +16,43 @@ export class BatchUtils { return false; } + // UI stencil depth must match for batching + if (elementA.uiStencilDepth !== elementB.uiStencilDepth || elementA.uiStencilOp !== elementB.uiStencilOp) { + return false; + } + const rendererA = elementA.component; const rendererB = elementB.component; const maskInteractionA = rendererA.maskInteraction; + const rendererAAny = rendererA as any; + const rendererBAny = rendererB as any; + const rectMaskEnabledA = rendererAAny._rectMaskEnabled; + if (rectMaskEnabledA !== rendererBAny._rectMaskEnabled) { + return false; + } + if (rectMaskEnabledA) { + const rectMaskRectA = rendererAAny._rectMaskRect; + const rectMaskRectB = rendererBAny._rectMaskRect; + const rectMaskSoftnessA = rendererAAny._rectMaskSoftness; + const rectMaskSoftnessB = rendererBAny._rectMaskSoftness; + if ( + !rectMaskRectA || + !rectMaskRectB || + !rectMaskSoftnessA || + !rectMaskSoftnessB || + rectMaskRectA.x !== rectMaskRectB.x || + rectMaskRectA.y !== rectMaskRectB.y || + rectMaskRectA.z !== rectMaskRectB.z || + rectMaskRectA.w !== rectMaskRectB.w || + rectMaskSoftnessA.x !== rectMaskSoftnessB.x || + rectMaskSoftnessA.y !== rectMaskSoftnessB.y || + rectMaskSoftnessA.z !== rectMaskSoftnessB.z || + rectMaskSoftnessA.w !== rectMaskSoftnessB.w || + rendererAAny._rectMaskHardClip !== rendererBAny._rectMaskHardClip + ) { + return false; + } + } // Compare mask, texture and material return ( diff --git a/packages/core/src/RenderPipeline/MaskManager.ts b/packages/core/src/RenderPipeline/MaskManager.ts index 8454e1d584..5a55481572 100644 --- a/packages/core/src/RenderPipeline/MaskManager.ts +++ b/packages/core/src/RenderPipeline/MaskManager.ts @@ -1,4 +1,6 @@ +import { Vector3 } from "@galacean/engine-math"; import { SpriteMask } from "../2d"; +import { SpriteMaskInteraction } from "../2d/enums/SpriteMaskInteraction"; import { CameraClearFlags } from "../enums/CameraClearFlags"; import { SpriteMaskLayer } from "../enums/SpriteMaskLayer"; import { Material } from "../material"; @@ -29,16 +31,55 @@ export class MaskManager { private _preMaskLayer = SpriteMaskLayer.Nothing; private _allSpriteMasks = new DisorderedArray(); + private _filteredMasksByLayer = new Map(); + private _isFilteredMasksDirty = true; addSpriteMask(mask: SpriteMask): void { mask._maskIndex = this._allSpriteMasks.length; this._allSpriteMasks.add(mask); + this._isFilteredMasksDirty = true; } removeSpriteMask(mask: SpriteMask): void { const replaced = this._allSpriteMasks.deleteByIndex(mask._maskIndex); replaced && (replaced._maskIndex = mask._maskIndex); mask._maskIndex = -1; + this._isFilteredMasksDirty = true; + } + + /** + * Called when a mask's influenceLayers changes. + */ + onMaskInfluenceLayersChange(): void { + this._isFilteredMasksDirty = true; + } + + /** + * Check if a world point is visible given the mask interaction and layer. + */ + isVisibleByMask( + maskInteraction: SpriteMaskInteraction, + maskLayer: SpriteMaskLayer, + worldPoint: Vector3 + ): boolean { + if (maskInteraction === SpriteMaskInteraction.None) { + return true; + } + + const masks = this._getMasksByLayer(maskLayer); + if (!masks || masks.length === 0) { + return maskInteraction === SpriteMaskInteraction.VisibleOutsideMask; + } + + let insideAny = false; + for (let i = 0, n = masks.length; i < n; i++) { + if (masks[i]._containsWorldPoint(worldPoint)) { + insideAny = true; + break; + } + } + + return maskInteraction === SpriteMaskInteraction.VisibleInsideMask ? insideAny : !insideAny; } drawMask(context: RenderContext, pipelineStageTagValue: string, maskLayer: SpriteMaskLayer): void { @@ -118,6 +159,28 @@ export class MaskManager { const allSpriteMasks = this._allSpriteMasks; allSpriteMasks.length = 0; allSpriteMasks.garbageCollection(); + this._filteredMasksByLayer.clear(); + } + + private _getMasksByLayer(maskLayer: SpriteMaskLayer): SpriteMask[] | undefined { + if (this._isFilteredMasksDirty) { + this._filteredMasksByLayer.clear(); + this._isFilteredMasksDirty = false; + } + + let filtered = this._filteredMasksByLayer.get(maskLayer); + if (filtered === undefined) { + filtered = []; + const masks = this._allSpriteMasks; + for (let i = 0, n = masks.length; i < n; i++) { + const mask = masks.get(i); + if (mask.influenceLayers & maskLayer) { + filtered.push(mask); + } + } + this._filteredMasksByLayer.set(maskLayer, filtered); + } + return filtered; } private _buildMaskRenderElement( diff --git a/packages/core/src/RenderPipeline/RenderQueue.ts b/packages/core/src/RenderPipeline/RenderQueue.ts index 3558679996..89aab2386c 100644 --- a/packages/core/src/RenderPipeline/RenderQueue.ts +++ b/packages/core/src/RenderPipeline/RenderQueue.ts @@ -75,16 +75,26 @@ export class RenderQueue { renderer._updateTransformShaderData(context, true, batched); } - const maskInteraction = renderer._maskInteraction; + const maskInteraction = (renderer as any)._maskInteraction ?? SpriteMaskInteraction.None; const needMaskInteraction = maskInteraction !== SpriteMaskInteraction.None; const needMaskType = maskType !== RenderQueueMaskType.No; let customStates: RenderStateElementMap = null; - if (needMaskType) { + // UI hierarchy-based stencil mask + const uiStencilDepth = subElement.uiStencilDepth; + if (uiStencilDepth > 0) { + if (subElement.uiStencilOp === 1) { + // Mask shape: write stencil (increment) + customStates = BasicResources.getUIStencilWriteStates(); + } else { + // Masked content: test stencil + customStates = BasicResources.getUIStencilTestStates(uiStencilDepth); + } + } else if (needMaskType) { customStates = BasicResources.getMaskTypeRenderStates(maskType); } else { if (needMaskInteraction) { - maskManager.drawMask(context, pipelineStageTagValue, subElement.component._maskLayer); + maskManager.drawMask(context, pipelineStageTagValue, (renderer as any)._maskLayer); customStates = BasicResources.getMaskInteractionRenderStates(maskInteraction); } else { maskManager.isReadStencil(material) && maskManager.clearMask(context, pipelineStageTagValue); diff --git a/packages/core/src/RenderPipeline/SubRenderElement.ts b/packages/core/src/RenderPipeline/SubRenderElement.ts index f8c86c114d..f010e8cfd7 100644 --- a/packages/core/src/RenderPipeline/SubRenderElement.ts +++ b/packages/core/src/RenderPipeline/SubRenderElement.ts @@ -17,6 +17,11 @@ export class SubRenderElement implements IPoolElement { batched: boolean; renderQueueFlags: RenderQueueFlags; + /** UI stencil depth. 0 = no stencil, >0 = stencil test/write at this depth. */ + uiStencilDepth: number = 0; + /** UI stencil operation. 0 = test (read stencil), 1 = increment (write mask), -1 = decrement (exit mask). */ + uiStencilOp: number = 0; + // @todo: maybe should remove later texture?: Texture2D; subChunk?: SubPrimitiveChunk; diff --git a/packages/core/src/Renderer.ts b/packages/core/src/Renderer.ts index ab9f743c0c..d193db9f77 100644 --- a/packages/core/src/Renderer.ts +++ b/packages/core/src/Renderer.ts @@ -1,6 +1,5 @@ // @ts-ignore import { BoundingBox, Matrix, Vector3, Vector4 } from "@galacean/engine-math"; -import { SpriteMaskInteraction } from "./2d/enums/SpriteMaskInteraction"; import { Component } from "./Component"; import { DependentMode, dependentComponents } from "./ComponentsDependencies"; import { Entity } from "./Entity"; @@ -8,7 +7,6 @@ import { RenderContext } from "./RenderPipeline/RenderContext"; import { SubRenderElement } from "./RenderPipeline/SubRenderElement"; import { Transform, TransformModifyFlags } from "./Transform"; import { assignmentClone, deepClone, ignoreClone } from "./clone/CloneManager"; -import { SpriteMaskLayer } from "./enums/SpriteMaskLayer"; import { Material } from "./material"; import { ShaderMacro, ShaderProperty } from "./shader"; import { ShaderData } from "./shader/ShaderData"; @@ -47,13 +45,8 @@ export class Renderer extends Component { @ignoreClone _renderFrameCount: number; /** @internal */ - @assignmentClone - _maskInteraction: SpriteMaskInteraction = SpriteMaskInteraction.None; - /** @internal */ @ignoreClone _batchedTransformShaderData: boolean = false; - @assignmentClone - _maskLayer: SpriteMaskLayer = SpriteMaskLayer.Layer0; @ignoreClone protected _overrideUpdate: boolean = false; diff --git a/packages/core/src/shaderlib/extra/text.fs.glsl b/packages/core/src/shaderlib/extra/text.fs.glsl index 8fe1125d69..019d419ba4 100644 --- a/packages/core/src/shaderlib/extra/text.fs.glsl +++ b/packages/core/src/shaderlib/extra/text.fs.glsl @@ -1,15 +1,46 @@ uniform sampler2D renderElement_TextTexture; +uniform vec4 renderer_UIRectClipRect; +uniform float renderer_UIRectClipEnabled; +uniform vec4 renderer_UIRectClipSoftness; +uniform float renderer_UIRectClipHardClip; varying vec2 v_uv; varying vec4 v_color; +varying vec2 v_worldPosition; + +float getUIRectClipAlpha() +{ + vec4 edgeDistance = vec4( + v_worldPosition.x - renderer_UIRectClipRect.x, + v_worldPosition.y - renderer_UIRectClipRect.y, + renderer_UIRectClipRect.z - v_worldPosition.x, + renderer_UIRectClipRect.w - v_worldPosition.y + ); + vec4 hardClipFactor = step(vec4(0.0), edgeDistance); + vec4 softness = max(renderer_UIRectClipSoftness, vec4(1e-5)); + vec4 softClipFactor = clamp(edgeDistance / softness, 0.0, 1.0); + vec4 useSoftness = step(vec4(1e-5), renderer_UIRectClipSoftness); + vec4 clipFactor = mix(hardClipFactor, softClipFactor, useSoftness); + return clipFactor.x * clipFactor.y * clipFactor.z * clipFactor.w; +} void main() { + float rectClipAlpha = 1.0; + if (renderer_UIRectClipEnabled > 0.5) { + rectClipAlpha = getUIRectClipAlpha(); + } + vec4 texColor = texture2D(renderElement_TextTexture, v_uv); #ifdef GRAPHICS_API_WEBGL2 float coverage = texColor.r; #else float coverage = texColor.a; #endif - gl_FragColor = vec4(v_color.rgb, v_color.a * coverage); + vec4 finalColor = vec4(v_color.rgb, v_color.a * coverage); + finalColor.a *= rectClipAlpha; + if (renderer_UIRectClipEnabled > 0.5 && renderer_UIRectClipHardClip > 0.5 && finalColor.a < 0.001) { + discard; + } + gl_FragColor = finalColor; } diff --git a/packages/core/src/shaderlib/extra/text.vs.glsl b/packages/core/src/shaderlib/extra/text.vs.glsl index 37a6b2d333..c3971d0172 100644 --- a/packages/core/src/shaderlib/extra/text.vs.glsl +++ b/packages/core/src/shaderlib/extra/text.vs.glsl @@ -1,4 +1,5 @@ uniform mat4 renderer_MVPMat; +uniform mat4 renderer_ModelMat; attribute vec3 POSITION; attribute vec2 TEXCOORD_0; @@ -6,6 +7,7 @@ attribute vec4 COLOR_0; varying vec2 v_uv; varying vec4 v_color; +varying vec2 v_worldPosition; void main() { @@ -13,4 +15,5 @@ void main() v_uv = TEXCOORD_0; v_color = COLOR_0; + v_worldPosition = POSITION.xy; } diff --git a/packages/ui/src/component/UICanvas.ts b/packages/ui/src/component/UICanvas.ts index 9e5b33c405..b998b8c91f 100644 --- a/packages/ui/src/component/UICanvas.ts +++ b/packages/ui/src/component/UICanvas.ts @@ -25,6 +25,8 @@ import { ResolutionAdaptationMode } from "../enums/ResolutionAdaptationMode"; import { UIHitResult } from "../input/UIHitResult"; import { IElement } from "../interface/IElement"; import { IGroupAble } from "../interface/IGroupAble"; +import { Mask } from "./advanced/Mask"; +import { RectMask2D } from "./advanced/RectMask2D"; import { UIGroup } from "./UIGroup"; import { UIRenderer } from "./UIRenderer"; import { UITransform } from "./UITransform"; @@ -39,6 +41,7 @@ export class UICanvas extends Component implements IElement { /** @internal */ static _hierarchyCounter: number = 1; private static _tempGroupAbleList: IGroupAble[] = []; + private static _tempRectMaskList: RectMask2D[] = []; private static _tempVec3: Vector3 = new Vector3(); private static _tempMat: Matrix = new Matrix(); @@ -418,7 +421,8 @@ export class UICanvas extends Component implements IElement { const { _orderedRenderers: renderers, entity } = this; const uiHierarchyVersion = entity._uiHierarchyVersion; if (this._hierarchyVersion !== uiHierarchyVersion) { - renderers.length = this._walk(this.entity, renderers); + UICanvas._tempRectMaskList.length = 0; + renderers.length = this._walk(this.entity, renderers, 0, null, 0); UICanvas._tempGroupAbleList.length = 0; this._hierarchyVersion = uiHierarchyVersion; ++UICanvas._hierarchyCounter; @@ -500,26 +504,51 @@ export class UICanvas extends Component implements IElement { transform.size.set(curWidth / expectX, curHeight / expectY); } - private _walk(entity: Entity, renderers: UIRenderer[], depth = 0, group: UIGroup = null): number { + private _walk( + entity: Entity, + renderers: UIRenderer[], + depth = 0, + group: UIGroup = null, + rectMaskCount: number = 0, + stencilDepth: number = 0 + ): number { // @ts-ignore const components: Component[] = entity._components; const tempGroupAbleList = UICanvas._tempGroupAbleList; + const tempRectMaskList = UICanvas._tempRectMaskList; + let rectMask: RectMask2D = null; + let hasMask = false; let groupAbleCount = 0; for (let i = 0, n = components.length; i < n; i++) { const component = components[i]; if (!component.enabled) continue; - if (component instanceof UIRenderer) { + if (component instanceof Mask) { + // Mask is a UIRenderer — process it as such, but also flag the mask + hasMask = true; renderers[depth] = component; ++depth; component._isRootCanvasDirty && Utils.setRootCanvas(component, this); if (component._isGroupDirty) { tempGroupAbleList[groupAbleCount++] = component; } + component._setRectMasks(tempRectMaskList, rectMaskCount); + // Mask's stencilDepth is set AFTER incrementing below + } else if (component instanceof UIRenderer) { + renderers[depth] = component; + ++depth; + component._isRootCanvasDirty && Utils.setRootCanvas(component, this); + if (component._isGroupDirty) { + tempGroupAbleList[groupAbleCount++] = component; + } + component._setRectMasks(tempRectMaskList, rectMaskCount); + component._uiStencilDepth = stencilDepth; } else if (component instanceof UIInteractive) { component._isRootCanvasDirty && Utils.setRootCanvas(component, this); if (component._isGroupDirty) { tempGroupAbleList[groupAbleCount++] = component; } + } else if (component instanceof RectMask2D) { + rectMask = component; } else if (component instanceof UIGroup) { component._isRootCanvasDirty && Utils.setRootCanvas(component, this); component._isGroupDirty && Utils.setGroup(component, group); @@ -529,10 +558,17 @@ export class UICanvas extends Component implements IElement { for (let i = 0; i < groupAbleCount; i++) { Utils.setGroup(tempGroupAbleList[i], group); } + if (rectMask) { + tempRectMaskList[rectMaskCount++] = rectMask; + } + // If this entity has a Mask, increment stencil depth for children + if (hasMask) { + stencilDepth++; + } const children = entity.children; for (let i = 0, n = children.length; i < n; i++) { const child = children[i]; - child.isActive && (depth = this._walk(child, renderers, depth, group)); + child.isActive && (depth = this._walk(child, renderers, depth, group, rectMaskCount, stencilDepth)); } return depth; } diff --git a/packages/ui/src/component/UIRenderer.ts b/packages/ui/src/component/UIRenderer.ts index 59a2fc434c..05275e2860 100644 --- a/packages/ui/src/component/UIRenderer.ts +++ b/packages/ui/src/component/UIRenderer.ts @@ -11,6 +11,7 @@ import { RendererUpdateFlags, ShaderMacroCollection, ShaderProperty, + SpriteMaskInteraction, Vector3, Vector4, assignmentClone, @@ -21,6 +22,7 @@ import { import { Utils } from "../Utils"; import { UIHitResult } from "../input/UIHitResult"; import { IGraphics } from "../interface/IGraphics"; +import { RectMask2D } from "./advanced/RectMask2D"; import { EntityUIModifyFlags, UICanvas } from "./UICanvas"; import { GroupModifyFlags, UIGroup } from "./UIGroup"; import { UITransform } from "./UITransform"; @@ -37,6 +39,16 @@ export class UIRenderer extends Renderer implements IGraphics { static _tempPlane: Plane = new Plane(); /** @internal */ static _textureProperty: ShaderProperty = ShaderProperty.getByName("renderer_UITexture"); + /** @internal */ + static _rectClipRectProperty: ShaderProperty = ShaderProperty.getByName("renderer_UIRectClipRect"); + /** @internal */ + static _rectClipEnabledProperty: ShaderProperty = ShaderProperty.getByName("renderer_UIRectClipEnabled"); + /** @internal */ + static _rectClipSoftnessProperty: ShaderProperty = ShaderProperty.getByName("renderer_UIRectClipSoftness"); + /** @internal */ + static _rectClipHardClipProperty: ShaderProperty = ShaderProperty.getByName("renderer_UIRectClipHardClip"); + /** @internal */ + static _tempRect: Vector4 = new Vector4(); /** * Custom boundary for raycast detection. @@ -69,6 +81,24 @@ export class UIRenderer extends Renderer implements IGraphics { /** @internal */ @ignoreClone _subChunk; + /** @internal */ + @ignoreClone + _rectMasks: RectMask2D[] = []; + /** @internal */ + @ignoreClone + _rectMaskRect: Vector4 = new Vector4(); + /** @internal */ + @ignoreClone + _rectMaskEnabled: boolean = false; + /** @internal */ + @ignoreClone + _rectMaskSoftness: Vector4 = new Vector4(); + /** @internal */ + @ignoreClone + _rectMaskHardClip: boolean = false; + /** @internal - stencil depth set by UICanvas._walk for hierarchy-based Mask */ + @ignoreClone + _uiStencilDepth: number = 0; @assignmentClone private _raycastEnabled: boolean = true; @@ -110,6 +140,9 @@ export class UIRenderer extends Renderer implements IGraphics { this._color._onValueChanged = this._onColorChanged; this._groupListener = this._groupListener.bind(this); this._rootCanvasListener = this._rootCanvasListener.bind(this); + this.shaderData.setFloat(UIRenderer._rectClipEnabledProperty, 0); + this.shaderData.setVector4(UIRenderer._rectClipSoftnessProperty, this._rectMaskSoftness); + this.shaderData.setFloat(UIRenderer._rectClipHardClipProperty, 0); } // @ts-ignore @@ -135,6 +168,7 @@ export class UIRenderer extends Renderer implements IGraphics { this._update(context); } + this._updateRectMaskClipState(); this._render(context); // union camera global macro and renderer macro. @@ -252,7 +286,11 @@ export class UIRenderer extends Renderer implements IGraphics { Matrix.invert(transform.worldMatrix, worldMatrixInv); const localPosition = UIRenderer._tempVec31; Vector3.transformCoordinate(hitPointWorld, worldMatrixInv, localPosition); - if (this._hitTest(localPosition)) { + if ( + this._hitTest(localPosition) && + this._isRaycastVisibleByRectMask(hitPointWorld) && + this._isRaycastVisibleByMask(hitPointWorld) + ) { out.component = this; out.distance = curDistance; out.entity = this.entity; @@ -278,6 +316,153 @@ export class UIRenderer extends Renderer implements IGraphics { ); } + /** + * @internal + */ + _setRectMasks(rectMasks: RectMask2D[], count: number): void { + const targetMasks = this._rectMasks; + targetMasks.length = count; + for (let i = 0; i < count; i++) { + targetMasks[i] = rectMasks[i]; + } + } + + private _isRaycastVisibleByMask(hitPointWorld: Vector3): boolean { + const maskInteraction = (this as any)._maskInteraction ?? SpriteMaskInteraction.None; + if (maskInteraction === SpriteMaskInteraction.None) { + return true; + } + // @ts-ignore + return this.scene._maskManager.isVisibleByMask(maskInteraction, (this as any)._maskLayer, hitPointWorld); + } + + private _isRaycastVisibleByRectMask(hitPointWorld: Vector3): boolean { + const rectMasks = this._rectMasks; + for (let i = 0, n = rectMasks.length; i < n; i++) { + const rectMask = rectMasks[i]; + if (!rectMask.enabled || !rectMask.entity.isActiveInHierarchy) { + continue; + } + if (!rectMask._containsWorldPoint(hitPointWorld)) { + return false; + } + } + return true; + } + + private _updateRectMaskClipState(): void { + const rectMasks = this._rectMasks; + const count = rectMasks.length; + if (count <= 0) { + if (this._rectMaskEnabled) { + this._rectMaskEnabled = false; + this.shaderData.setFloat(UIRenderer._rectClipEnabledProperty, 0); + } + const rectMaskSoftness = this._rectMaskSoftness; + if (rectMaskSoftness.x !== 0 || rectMaskSoftness.y !== 0 || rectMaskSoftness.z !== 0 || rectMaskSoftness.w !== 0) { + rectMaskSoftness.set(0, 0, 0, 0); + this.shaderData.setVector4(UIRenderer._rectClipSoftnessProperty, rectMaskSoftness); + } + if (this._rectMaskHardClip) { + this._rectMaskHardClip = false; + this.shaderData.setFloat(UIRenderer._rectClipHardClipProperty, 0); + } + return; + } + + let minX = Number.NEGATIVE_INFINITY; + let minY = Number.NEGATIVE_INFINITY; + let maxX = Number.POSITIVE_INFINITY; + let maxY = Number.POSITIVE_INFINITY; + let clipSoftnessLeft = 0; + let clipSoftnessBottom = 0; + let clipSoftnessRight = 0; + let clipSoftnessTop = 0; + let clipHardClip = false; + let hasActiveMask = false; + const tempRect = UIRenderer._tempRect; + for (let i = 0; i < count; i++) { + const rectMask = rectMasks[i]; + if (!rectMask.enabled || !rectMask.entity.isActiveInHierarchy) { + continue; + } + hasActiveMask = true; + const softness = rectMask.softness; + if (!clipHardClip && rectMask.alphaClip) { + clipHardClip = true; + } + if (!rectMask._getWorldRect(tempRect)) { + minX = 1; + minY = 1; + maxX = 0; + maxY = 0; + break; + } + if (tempRect.x > minX) { + minX = tempRect.x; + clipSoftnessLeft = softness.x; + } + if (tempRect.y > minY) { + minY = tempRect.y; + clipSoftnessBottom = softness.y; + } + if (tempRect.z < maxX) { + maxX = tempRect.z; + clipSoftnessRight = softness.x; + } + if (tempRect.w < maxY) { + maxY = tempRect.w; + clipSoftnessTop = softness.y; + } + } + + if (!hasActiveMask) { + if (this._rectMaskEnabled) { + this._rectMaskEnabled = false; + this.shaderData.setFloat(UIRenderer._rectClipEnabledProperty, 0); + } + return; + } + + if (minX >= maxX || minY >= maxY) { + minX = 1; + minY = 1; + maxX = 0; + maxY = 0; + clipSoftnessLeft = 0; + clipSoftnessBottom = 0; + clipSoftnessRight = 0; + clipSoftnessTop = 0; + } + + const rectMaskRect = this._rectMaskRect; + if (rectMaskRect.x !== minX || rectMaskRect.y !== minY || rectMaskRect.z !== maxX || rectMaskRect.w !== maxY) { + rectMaskRect.set(minX, minY, maxX, maxY); + this.shaderData.setVector4(UIRenderer._rectClipRectProperty, rectMaskRect); + } + + const rectMaskSoftness = this._rectMaskSoftness; + if ( + rectMaskSoftness.x !== clipSoftnessLeft || + rectMaskSoftness.y !== clipSoftnessBottom || + rectMaskSoftness.z !== clipSoftnessRight || + rectMaskSoftness.w !== clipSoftnessTop + ) { + rectMaskSoftness.set(clipSoftnessLeft, clipSoftnessBottom, clipSoftnessRight, clipSoftnessTop); + this.shaderData.setVector4(UIRenderer._rectClipSoftnessProperty, rectMaskSoftness); + } + + if (this._rectMaskHardClip !== clipHardClip) { + this._rectMaskHardClip = clipHardClip; + this.shaderData.setFloat(UIRenderer._rectClipHardClipProperty, clipHardClip ? 1 : 0); + } + + if (!this._rectMaskEnabled) { + this._rectMaskEnabled = true; + this.shaderData.setFloat(UIRenderer._rectClipEnabledProperty, 1); + } + } + protected override _onDestroy(): void { if (this._subChunk) { this._getChunkManager().freeSubChunk(this._subChunk); @@ -287,6 +472,8 @@ export class UIRenderer extends Renderer implements IGraphics { //@ts-ignore this._color._onValueChanged = null; this._color = null; + this._rectMasks = null; + this._rectMaskSoftness = null; } } diff --git a/packages/ui/src/component/advanced/Image.ts b/packages/ui/src/component/advanced/Image.ts index cb94a7a640..b91a5f0839 100644 --- a/packages/ui/src/component/advanced/Image.ts +++ b/packages/ui/src/component/advanced/Image.ts @@ -57,6 +57,13 @@ export class Image extends SpriteRenderable(UIRenderer) { subRenderElement.renderQueueFlags = RenderQueueFlags.All; } + // Set UI stencil depth for hierarchy-based masking + const stencilDepth = this._uiStencilDepth; + if (stencilDepth > 0) { + subRenderElement.uiStencilDepth = stencilDepth; + subRenderElement.uiStencilOp = 0; // test (read stencil) + } + canvas._renderElement.addSubRenderElement(subRenderElement); } diff --git a/packages/ui/src/component/advanced/Mask.ts b/packages/ui/src/component/advanced/Mask.ts new file mode 100644 index 0000000000..9c136e6dc0 --- /dev/null +++ b/packages/ui/src/component/advanced/Mask.ts @@ -0,0 +1,139 @@ +import { + BoundingBox, + Color, + Entity, + Material, + PrimitiveChunkManager, + RenderContext, + RenderQueueFlags, + ShaderProperty, + SubPrimitiveChunk, + Texture2D, + SpriteRenderable, + assignmentClone, + ignoreClone +} from "@galacean/engine"; +import type { Vector2 } from "@galacean/engine"; +import { CanvasRenderMode } from "../../enums/CanvasRenderMode"; +import { UIRenderer } from "../UIRenderer"; +import { UITransform } from "../UITransform"; + +/** + * UI component that masks descendant UI elements using a sprite shape. + * + * @remarks + * Uses stencil buffer. All UIRenderers that are descendants of the Mask's entity + * are automatically clipped to the mask shape — no manual maskInteraction setup needed. + */ +export class Mask extends SpriteRenderable(UIRenderer) { + /** @internal */ + static _maskTextureProperty: ShaderProperty = ShaderProperty.getByName("renderer_MaskTexture"); + /** @internal */ + static _alphaCutoffProperty: ShaderProperty = ShaderProperty.getByName("renderer_MaskAlphaCutoff"); + + private static _defaultColor: Color = new Color(1, 1, 1, 1); + + @assignmentClone + private _alphaCutoff: number = 0.5; + + /** + * The minimum alpha value used by the mask to select the area of influence. + * Value between 0 and 1. + */ + get alphaCutoff(): number { + return this._alphaCutoff; + } + + set alphaCutoff(value: number) { + if (this._alphaCutoff !== value) { + this._alphaCutoff = value; + this.shaderData.setFloat(Mask._alphaCutoffProperty, value); + } + } + + /** + * @internal + */ + override get color(): Color { + return Mask._defaultColor; + } + + /** + * @internal + */ + constructor(entity: Entity) { + super(entity); + this._initSpriteRenderable(Mask._maskTextureProperty); + this.shaderData.setFloat(Mask._alphaCutoffProperty, this._alphaCutoff); + this.raycastEnabled = false; + } + + // ===== SpriteRenderable abstract implementations ===== + + /** @internal */ + override _getChunkManager(): PrimitiveChunkManager { + // @ts-ignore + return this.engine._batcherManager.primitiveChunkManagerMask; + } + + /** @internal */ + override _getDefaultSpriteMaterial(): Material { + // @ts-ignore + return this._engine._basicResources.spriteMaskDefaultMaterial; + } + + /** @internal */ + override _submitSpriteRenderElement( + context: RenderContext, + material: Material, + subChunk: SubPrimitiveChunk, + texture: Texture2D + ): void { + const canvas = this._getRootCanvas(); + if (!canvas) return; + + const engine = context.camera.engine; + const subRenderElement = engine._subRenderElementPool.get(); + subRenderElement.set(this, material, subChunk.chunk.primitive, subChunk.subMesh, texture, subChunk); + subRenderElement.shaderPasses = material.shader.subShaders[0].passes; + subRenderElement.renderQueueFlags = RenderQueueFlags.All; + + // Mark as stencil write (increment) at current stencil depth + subRenderElement.uiStencilDepth = this._uiStencilDepth; + subRenderElement.uiStencilOp = 1; // increment + + canvas._renderElement.addSubRenderElement(subRenderElement); + } + + /** @internal */ + override _getSpriteWidth(): number { + return (this._transformEntity.transform).size.x; + } + + /** @internal */ + override _getSpriteHeight(): number { + return (this._transformEntity.transform).size.y; + } + + /** @internal */ + override _getSpritePivot(): Vector2 { + return (this._transformEntity.transform).pivot; + } + + protected override _updateBounds(worldBounds: BoundingBox): void { + const rootCanvas = this._getRootCanvas(); + if (this.sprite && rootCanvas) { + super._updateBounds(worldBounds); + } else { + const { worldPosition } = this._transformEntity.transform; + worldBounds.min.copyFrom(worldPosition); + worldBounds.max.copyFrom(worldPosition); + } + } + + @ignoreClone + protected override _onTransformChanged(type: number): void { + // @ts-ignore + this._dirtyUpdateFlag |= 0x1; // RendererUpdateFlags.WorldVolume + } +} diff --git a/packages/ui/src/component/advanced/RectMask2D.ts b/packages/ui/src/component/advanced/RectMask2D.ts new file mode 100644 index 0000000000..7fd90c26a7 --- /dev/null +++ b/packages/ui/src/component/advanced/RectMask2D.ts @@ -0,0 +1,157 @@ +import { + Component, + DependentMode, + Entity, + Vector2, + Vector3, + Vector4, + assignmentClone, + deepClone, + dependentComponents +} from "@galacean/engine"; +import { UICanvas } from "../UICanvas"; +import { UITransform } from "../UITransform"; + +/** + * UI component that clips descendant graphics by an axis-aligned rectangle. + */ +@dependentComponents(UITransform, DependentMode.AutoAdd) +export class RectMask2D extends Component { + private static _tempRect: Vector4 = new Vector4(); + private static _tempCorner0: Vector3 = new Vector3(); + private static _tempCorner1: Vector3 = new Vector3(); + private static _tempCorner2: Vector3 = new Vector3(); + private static _tempCorner3: Vector3 = new Vector3(); + + @deepClone + private _softness: Vector2 = new Vector2(0, 0); + @assignmentClone + private _alphaClip: boolean = false; + + /** + * Soft clipping width on X/Y axis in world space. + */ + get softness(): Vector2 { + return this._softness; + } + + set softness(value: Vector2) { + const softness = this._softness; + if (softness === value) { + return; + } + if (softness.x !== value.x || softness.y !== value.y) { + softness.copyFrom(value); + this._clampSoftness(); + } + } + + /** + * Whether to enable hard clip (discard) when outside the rect. + */ + get alphaClip(): boolean { + return this._alphaClip; + } + + set alphaClip(value: boolean) { + this._alphaClip = value; + } + + /** + * @internal + */ + _getWorldRect(out: Vector4): boolean { + const transform = this.entity.transform; + const { x: width, y: height } = transform.size; + if (!width || !height) { + return false; + } + + const { x: pivotX, y: pivotY } = transform.pivot; + const left = -width * pivotX; + const right = width * (1 - pivotX); + const bottom = -height * pivotY; + const top = height * (1 - pivotY); + + const worldMatrix = transform.worldMatrix; + const corner0 = RectMask2D._tempCorner0; + const corner1 = RectMask2D._tempCorner1; + const corner2 = RectMask2D._tempCorner2; + const corner3 = RectMask2D._tempCorner3; + Vector3.transformCoordinate(corner0.set(left, bottom, 0), worldMatrix, corner0); + Vector3.transformCoordinate(corner1.set(left, top, 0), worldMatrix, corner1); + Vector3.transformCoordinate(corner2.set(right, bottom, 0), worldMatrix, corner2); + Vector3.transformCoordinate(corner3.set(right, top, 0), worldMatrix, corner3); + + const minX = Math.min(corner0.x, corner1.x, corner2.x, corner3.x); + const minY = Math.min(corner0.y, corner1.y, corner2.y, corner3.y); + const maxX = Math.max(corner0.x, corner1.x, corner2.x, corner3.x); + const maxY = Math.max(corner0.y, corner1.y, corner2.y, corner3.y); + out.set(minX, minY, maxX, maxY); + return true; + } + + /** + * @internal + */ + _containsWorldPoint(worldPoint: Vector3): boolean { + const worldRect = RectMask2D._tempRect; + if (!this._getWorldRect(worldRect)) { + return false; + } + const { x, y } = worldPoint; + return x >= worldRect.x && x <= worldRect.z && y >= worldRect.y && y <= worldRect.w; + } + + constructor(entity: Entity) { + super(entity); + this._onSoftnessChanged = this._onSoftnessChanged.bind(this); + // @ts-ignore + this._softness._onValueChanged = this._onSoftnessChanged; + } + + // @ts-ignore + override _onEnableInScene(): void { + this.entity._updateUIHierarchyVersion(UICanvas._hierarchyCounter); + } + + // @ts-ignore + override _onDisableInScene(): void { + this.entity._updateUIHierarchyVersion(UICanvas._hierarchyCounter); + } + + // @ts-ignore + override _cloneTo(target: RectMask2D, srcRoot: Entity, targetRoot: Entity): void { + // @ts-ignore + super._cloneTo(target, srcRoot, targetRoot); + + const targetSoftness = target._softness; + // @ts-ignore + targetSoftness._onValueChanged = null; + targetSoftness.copyFrom(this._softness); + target._clampSoftness(); + // @ts-ignore + targetSoftness._onValueChanged = target._onSoftnessChanged; + } + + protected override _onDestroy(): void { + // @ts-ignore + this._softness._onValueChanged = null; + this._softness = null; + super._onDestroy(); + } + + private _onSoftnessChanged(): void { + this._clampSoftness(); + } + + private _clampSoftness(): void { + const softness = this._softness; + if (softness.x < 0) { + softness.x = 0; + } + if (softness.y < 0) { + softness.y = 0; + } + } +} diff --git a/packages/ui/src/component/advanced/Text.ts b/packages/ui/src/component/advanced/Text.ts index 8192f87952..befc05c2d9 100644 --- a/packages/ui/src/component/advanced/Text.ts +++ b/packages/ui/src/component/advanced/Text.ts @@ -68,6 +68,12 @@ export class Text extends TextRenderable(UIRenderer) { subRenderElement.shaderPasses = material.shader.subShaders[0].passes; subRenderElement.renderQueueFlags = RenderQueueFlags.All; } + // Set UI stencil depth for hierarchy-based masking + const stencilDepth = this._uiStencilDepth; + if (stencilDepth > 0) { + subRenderElement.uiStencilDepth = stencilDepth; + subRenderElement.uiStencilOp = 0; // test (read stencil) + } renderElement.addSubRenderElement(subRenderElement); } } @@ -84,17 +90,6 @@ export class Text extends TextRenderable(UIRenderer) { // ===== Text-specific ===== - /** - * The mask layer the text belongs to. - */ - get maskLayer(): number { - return this._maskLayer; - } - - set maskLayer(value: number) { - this._maskLayer = value; - } - /** * @internal */ diff --git a/packages/ui/src/component/index.ts b/packages/ui/src/component/index.ts index 1f89431265..807e96c4e1 100644 --- a/packages/ui/src/component/index.ts +++ b/packages/ui/src/component/index.ts @@ -4,6 +4,8 @@ export { UIRenderer } from "./UIRenderer"; export { UITransform } from "./UITransform"; export { Button } from "./advanced/Button"; export { Image } from "./advanced/Image"; +export { Mask } from "./advanced/Mask"; +export { RectMask2D } from "./advanced/RectMask2D"; export { Text } from "./advanced/Text"; export { ColorTransition } from "./interactive/transition/ColorTransition"; export { ScaleTransition } from "./interactive/transition/ScaleTransition"; diff --git a/packages/ui/src/shader/uiDefault.fs.glsl b/packages/ui/src/shader/uiDefault.fs.glsl index e4028405de..31d8465895 100644 --- a/packages/ui/src/shader/uiDefault.fs.glsl +++ b/packages/ui/src/shader/uiDefault.fs.glsl @@ -1,12 +1,42 @@ #include uniform sampler2D renderer_UITexture; +uniform vec4 renderer_UIRectClipRect; +uniform float renderer_UIRectClipEnabled; +uniform vec4 renderer_UIRectClipSoftness; +uniform float renderer_UIRectClipHardClip; varying vec2 v_uv; varying vec4 v_color; +varying vec2 v_worldPosition; + +float getUIRectClipAlpha() +{ + vec4 edgeDistance = vec4( + v_worldPosition.x - renderer_UIRectClipRect.x, + v_worldPosition.y - renderer_UIRectClipRect.y, + renderer_UIRectClipRect.z - v_worldPosition.x, + renderer_UIRectClipRect.w - v_worldPosition.y + ); + vec4 hardClipFactor = step(vec4(0.0), edgeDistance); + vec4 softness = max(renderer_UIRectClipSoftness, vec4(1e-5)); + vec4 softClipFactor = clamp(edgeDistance / softness, 0.0, 1.0); + vec4 useSoftness = step(vec4(1e-5), renderer_UIRectClipSoftness); + vec4 clipFactor = mix(hardClipFactor, softClipFactor, useSoftness); + return clipFactor.x * clipFactor.y * clipFactor.z * clipFactor.w; +} void main() { + float rectClipAlpha = 1.0; + if (renderer_UIRectClipEnabled > 0.5) { + rectClipAlpha = getUIRectClipAlpha(); + } + vec4 baseColor = texture2DSRGB(renderer_UITexture, v_uv); vec4 finalColor = baseColor * v_color; + finalColor.a *= rectClipAlpha; + if (renderer_UIRectClipEnabled > 0.5 && renderer_UIRectClipHardClip > 0.5 && finalColor.a < 0.001) { + discard; + } #ifdef ENGINE_SHOULD_SRGB_CORRECT finalColor = outputSRGBCorrection(finalColor); #endif diff --git a/packages/ui/src/shader/uiDefault.vs.glsl b/packages/ui/src/shader/uiDefault.vs.glsl index 2a6b45be4e..52345d9abf 100644 --- a/packages/ui/src/shader/uiDefault.vs.glsl +++ b/packages/ui/src/shader/uiDefault.vs.glsl @@ -1,4 +1,5 @@ uniform mat4 renderer_MVPMat; +uniform mat4 renderer_ModelMat; attribute vec3 POSITION; attribute vec2 TEXCOORD_0; @@ -6,10 +7,12 @@ attribute vec4 COLOR_0; varying vec2 v_uv; varying vec4 v_color; +varying vec2 v_worldPosition; void main() { gl_Position = renderer_MVPMat * vec4(POSITION, 1.0); v_uv = TEXCOORD_0; v_color = COLOR_0; + v_worldPosition = POSITION.xy; } diff --git a/tests/src/core/SpriteMask.test.ts b/tests/src/core/SpriteMask.test.ts index d35012ffe0..7b9d31229f 100644 --- a/tests/src/core/SpriteMask.test.ts +++ b/tests/src/core/SpriteMask.test.ts @@ -1,4 +1,11 @@ -import { RendererUpdateFlags, Sprite, SpriteMask, SpriteMaskLayer, Texture2D } from "@galacean/engine-core"; +import { + RendererUpdateFlags, + Sprite, + SpriteMask, + SpriteMaskLayer, + SpriteRenderableFlags, + Texture2D +} from "@galacean/engine-core"; import { Rect, Vector2, Vector3, Vector4 } from "@galacean/engine-math"; import { WebGLEngine } from "@galacean/engine-rhi-webgl"; import { beforeEach, describe, expect, it } from "vitest"; @@ -124,28 +131,28 @@ describe("SpriteMask", async () => { expect(!!(spriteMask._dirtyUpdateFlag & RendererUpdateFlags.WorldVolume)).to.eq(true); // @ts-ignore - spriteMask._dirtyUpdateFlag &= ~SpriteMaskUpdateFlags.WorldVolumeAndUV; + spriteMask._dirtyUpdateFlag &= ~SpriteRenderableFlags.WorldVolumeAndUV; sprite.region = new Rect(); // @ts-ignore - expect(!!(spriteMask._dirtyUpdateFlag & SpriteMaskUpdateFlags.WorldVolumeAndUV)).to.eq(true); + expect(!!(spriteMask._dirtyUpdateFlag & SpriteRenderableFlags.WorldVolumeAndUV)).to.eq(true); // @ts-ignore - spriteMask._dirtyUpdateFlag &= ~SpriteMaskUpdateFlags.WorldVolumeAndUV; + spriteMask._dirtyUpdateFlag &= ~SpriteRenderableFlags.WorldVolumeAndUV; sprite.atlasRegionOffset = new Vector4(); // @ts-ignore - expect(!!(spriteMask._dirtyUpdateFlag & SpriteMaskUpdateFlags.WorldVolumeAndUV)).to.eq(true); + expect(!!(spriteMask._dirtyUpdateFlag & SpriteRenderableFlags.WorldVolumeAndUV)).to.eq(true); // @ts-ignore - spriteMask._dirtyUpdateFlag &= ~SpriteMaskUpdateFlags.UV; + spriteMask._dirtyUpdateFlag &= ~SpriteRenderableFlags.UV; sprite.atlasRegion = new Rect(); // @ts-ignore - expect(!!(spriteMask._dirtyUpdateFlag & SpriteMaskUpdateFlags.UV)).to.eq(true); + expect(!!(spriteMask._dirtyUpdateFlag & SpriteRenderableFlags.UV)).to.eq(true); // @ts-ignore - spriteMask._dirtyUpdateFlag &= ~SpriteMaskUpdateFlags.WorldVolumeAndUV; + spriteMask._dirtyUpdateFlag &= ~SpriteRenderableFlags.WorldVolumeAndUV; sprite.pivot = new Vector2(0.3, 0.2); // @ts-ignore - expect(!!(spriteMask._dirtyUpdateFlag & SpriteMaskUpdateFlags.WorldVolumeAndUV)).to.eq(true); + expect(!!(spriteMask._dirtyUpdateFlag & SpriteRenderableFlags.WorldVolumeAndUV)).to.eq(true); }); it("clone", () => { @@ -222,17 +229,3 @@ describe("SpriteMask", async () => { expect(spriteMask.bounds.max).to.deep.eq(new Vector3(0.5, 1, 0)); }); }); - -/** - * @remarks Extends `RendererUpdateFlags`. - */ -enum SpriteMaskUpdateFlags { - /** UV. */ - UV = 0x2, - /** Automatic Size. */ - AutomaticSize = 0x4, - /** WorldVolume and UV. */ - WorldVolumeAndUV = 0x3, - /** All. */ - All = 0x7 -} From b49d0059a4f01634801ead6f8541147e31a24800 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Mon, 23 Mar 2026 15:44:49 +0800 Subject: [PATCH 6/9] feat: delete examples --- examples/src/text-chunk-leak.ts | 157 -------------------------------- 1 file changed, 157 deletions(-) delete mode 100644 examples/src/text-chunk-leak.ts diff --git a/examples/src/text-chunk-leak.ts b/examples/src/text-chunk-leak.ts deleted file mode 100644 index ce6e47c22e..0000000000 --- a/examples/src/text-chunk-leak.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * @title Text Chunk Leak - * @category 2D - */ -import { - Camera, - Color, - Entity, - Script, - WebGLEngine, - TextHorizontalAlignment, -} from "@galacean/engine"; -import { - CanvasRenderMode, - registerGUI, - Text, - UICanvas, - UITransform, -} from "@galacean/engine-ui"; - -registerGUI(); - -/** - * Bug reproduction: - * - * Text components share a single PrimitiveChunk (vertex buffer). - * When entity.isActive = false, _onDisableInScene does NOT call - * _freeTextChunks(), so the inactive Text's vertices remain in the - * shared buffer and are still drawn. - * - * Steps: - * 1. Text A ("得分:48") renders at top (allocates SubChunk in shared buffer) - * 2. After 3s, Text A's parent is disabled (isActive=false) - * → SubChunk NOT freed, vertices remain in buffer - * 3. Text B ("历史最高:48") activates at center - * → allocates new SubChunk in SAME PrimitiveChunk - * 4. Draw call submits entire buffer → Text A's old vertices still drawn - */ -class Controller extends Script { - textA_parent: Entity; - textB_parent: Entity; - textAScore: Text; - - private _elapsed = 0; - private _score = 0; - private _switched = false; - - onUpdate(dt: number) { - this._elapsed += dt; - - if (!this._switched) { - // Update score every 0.3s to trigger chunk reallocation - if (this._elapsed > 0.3) { - this._elapsed -= 0.3; - this._score += Math.floor(Math.random() * 10) + 1; - this.textAScore.text = "" + this._score; - } - // After score > 40, switch panels - if (this._score > 40) { - this._switched = true; - // Hide panel A → chunks NOT freed (the bug) - this.textA_parent.isActive = false; - // Show panel B - this.textB_parent.isActive = true; - } - } - } -} - -async function main() { - const engine = await WebGLEngine.create({ - canvas: document.getElementById("canvas") as HTMLCanvasElement, - }); - engine.canvas.resizeByClientSize(); - - const scene = engine.sceneManager.activeScene; - scene.background.solidColor.set(0.35, 0.3, 0.25, 1); - const rootEntity = scene.createRootEntity(); - - const cameraEntity = rootEntity.createChild("camera"); - cameraEntity.transform.setPosition(0, 0, 10); - const camera = cameraEntity.addComponent(Camera); - camera.isOrthographic = true; - camera.orthographicSize = 5; - - // UICanvas - const canvasEntity = rootEntity.createChild("Canvas"); - const uiCanvas = canvasEntity.addComponent(UICanvas); - uiCanvas.renderMode = CanvasRenderMode.ScreenSpaceOverlay; - uiCanvas.referenceResolutionX = 750; - uiCanvas.referenceResolutionY = 1334; - - // ========== Panel A: HUD (visible initially) ========== - const panelA = canvasEntity.createChild("panelA"); - - const labelA = panelA.createChild("labelA"); - labelA.getComponent(UITransform).setPosition(-40, 300, 0); - const textA = labelA.addComponent(Text); - textA.text = "得分:"; - textA.fontSize = 36; - textA.color = new Color(1, 1, 1, 1); - textA.enableWrapping = true; - - const scoreA = panelA.createChild("scoreA"); - scoreA.getComponent(UITransform).setPosition(60, 300, 0); - const textAScore = scoreA.addComponent(Text); - textAScore.text = "0"; - textAScore.fontSize = 36; - textAScore.color = new Color(1, 1, 1, 1); - textAScore.enableWrapping = true; - - // ========== Panel B: GameOver (hidden initially) ========== - const panelB = canvasEntity.createChild("panelB"); - panelB.isActive = false; - - const labelB1 = panelB.createChild("labelB1"); - labelB1.getComponent(UITransform).setPosition(-60, 50, 0); - const textB1 = labelB1.addComponent(Text); - textB1.text = "历史最高:"; - textB1.fontSize = 40; - textB1.color = new Color(1, 1, 1, 1); - textB1.enableWrapping = true; - - const scoreB1 = panelB.createChild("scoreB1"); - scoreB1.getComponent(UITransform).setPosition(120, 50, 0); - const textBScore1 = scoreB1.addComponent(Text); - textBScore1.text = "99"; - textBScore1.fontSize = 40; - textBScore1.color = new Color(1, 1, 1, 1); - textBScore1.enableWrapping = true; - - const labelB2 = panelB.createChild("labelB2"); - labelB2.getComponent(UITransform).setPosition(-60, -50, 0); - const textB2 = labelB2.addComponent(Text); - textB2.text = "当前得分:"; - textB2.fontSize = 40; - textB2.color = new Color(1, 1, 1, 1); - textB2.enableWrapping = true; - - const scoreB2 = panelB.createChild("scoreB2"); - scoreB2.getComponent(UITransform).setPosition(120, -50, 0); - const textBScore2 = scoreB2.addComponent(Text); - textBScore2.text = "0"; - textBScore2.fontSize = 40; - textBScore2.color = new Color(1, 1, 1, 1); - textBScore2.enableWrapping = true; - - // ========== Controller ========== - const ctrl = rootEntity.addComponent(Controller); - ctrl.textA_parent = panelA; - ctrl.textB_parent = panelB; - ctrl.textAScore = textAScore; - - engine.run(); -} - -main(); From 8e7fc13f69dd24e9d7bdaedee8c40fec166cc6b2 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Mon, 23 Mar 2026 17:52:13 +0800 Subject: [PATCH 7/9] feat: udpate code --- packages/core/src/2d/sprite/SpriteMask.ts | 11 +---------- packages/core/src/2d/sprite/SpriteRenderable.ts | 16 ++++++++++------ packages/core/src/2d/sprite/SpriteRenderer.ts | 5 +++++ packages/ui/src/component/advanced/Image.ts | 5 +++++ packages/ui/src/component/advanced/Mask.ts | 10 ---------- 5 files changed, 21 insertions(+), 26 deletions(-) diff --git a/packages/core/src/2d/sprite/SpriteMask.ts b/packages/core/src/2d/sprite/SpriteMask.ts index 83744b6b0e..ef62794311 100644 --- a/packages/core/src/2d/sprite/SpriteMask.ts +++ b/packages/core/src/2d/sprite/SpriteMask.ts @@ -1,4 +1,4 @@ -import { Color, Vector2, Vector3 } from "@galacean/engine-math"; +import { Vector2, Vector3 } from "@galacean/engine-math"; import { Entity } from "../../Entity"; import { RenderQueueFlags } from "../../RenderPipeline/BasicRenderPipeline"; import { BatchUtils } from "../../RenderPipeline/BatchUtils"; @@ -25,8 +25,6 @@ export class SpriteMask extends SpriteRenderable(Renderer) { /** @internal */ static _alphaCutoffProperty: ShaderProperty = ShaderProperty.getByName("renderer_MaskAlphaCutoff"); - private static _defaultColor: Color = new Color(1, 1, 1, 1); - /** The mask layers the sprite mask influence to. */ @assignmentClone influenceLayers: SpriteMaskLayer = SpriteMaskLayer.Everything; @@ -145,13 +143,6 @@ export class SpriteMask extends SpriteRenderable(Renderer) { } } - /** - * @internal - */ - get color(): Color { - return SpriteMask._defaultColor; - } - /** * @internal */ diff --git a/packages/core/src/2d/sprite/SpriteRenderable.ts b/packages/core/src/2d/sprite/SpriteRenderable.ts index 702bc6a559..92d691f067 100644 --- a/packages/core/src/2d/sprite/SpriteRenderable.ts +++ b/packages/core/src/2d/sprite/SpriteRenderable.ts @@ -55,6 +55,7 @@ export interface ISpriteRenderable { _spriteData: SpritePrimitive; _getChunkManager(): PrimitiveChunkManager; _getDefaultSpriteMaterial(): Material; + _getSpriteColor(): Color | null; _getSpriteAlpha(): number; _getSpriteWidth(): number; _getSpriteHeight(): number; @@ -106,9 +107,6 @@ export function SpriteRenderable( // ===== Abstract methods: host MUST implement ===== - /** The color used by assemblers. */ - abstract get color(): Color; - /** Which PrimitiveChunkManager to allocate vertex data from. */ abstract _getChunkManager(): PrimitiveChunkManager; @@ -131,6 +129,11 @@ export function SpriteRenderable( // ===== Methods with defaults: host CAN override ===== + /** Sprite color for vertex coloring. Default: null (no color, for masks). */ + _getSpriteColor(): Color | null { + return null; + } + /** Final alpha multiplier. Default: 1. UI hosts override to globalAlpha. */ _getSpriteAlpha(): number { return 1; @@ -349,8 +352,9 @@ export function SpriteRenderable( material = this._getDefaultSpriteMaterial(); } + const color = this._getSpriteColor(); const alpha = this._getSpriteAlpha(); - if (this.color.a * alpha <= 0) { + if (color && color.a * alpha <= 0) { return; } @@ -380,8 +384,8 @@ export function SpriteRenderable( } // Update color - if (this._dirtyUpdateFlag & SpriteRenderableFlags.Color) { - this._assembler.updateColor(this._spriteData, this.color, alpha); + if (color && this._dirtyUpdateFlag & SpriteRenderableFlags.Color) { + this._assembler.updateColor(this._spriteData, color, alpha); this._dirtyUpdateFlag &= ~SpriteRenderableFlags.Color; } diff --git a/packages/core/src/2d/sprite/SpriteRenderer.ts b/packages/core/src/2d/sprite/SpriteRenderer.ts index 9e5fe63858..e9929e8c21 100644 --- a/packages/core/src/2d/sprite/SpriteRenderer.ts +++ b/packages/core/src/2d/sprite/SpriteRenderer.ts @@ -143,6 +143,11 @@ export class SpriteRenderer extends SpriteRenderable(Renderer) { // ===== Abstract implementations ===== + /** @internal */ + override _getSpriteColor(): Color { + return this._color; + } + /** @internal */ override _getChunkManager(): PrimitiveChunkManager { return this.engine._batcherManager.primitiveChunkManager2D; diff --git a/packages/ui/src/component/advanced/Image.ts b/packages/ui/src/component/advanced/Image.ts index b91a5f0839..ac19c057c8 100644 --- a/packages/ui/src/component/advanced/Image.ts +++ b/packages/ui/src/component/advanced/Image.ts @@ -32,6 +32,11 @@ export class Image extends SpriteRenderable(UIRenderer) { // ===== Abstract implementations ===== + /** @internal */ + _getSpriteColor() { + return this._color; + } + /** @internal */ override _getDefaultSpriteMaterial(): Material { // @ts-ignore diff --git a/packages/ui/src/component/advanced/Mask.ts b/packages/ui/src/component/advanced/Mask.ts index 9c136e6dc0..351507b917 100644 --- a/packages/ui/src/component/advanced/Mask.ts +++ b/packages/ui/src/component/advanced/Mask.ts @@ -1,6 +1,5 @@ import { BoundingBox, - Color, Entity, Material, PrimitiveChunkManager, @@ -31,8 +30,6 @@ export class Mask extends SpriteRenderable(UIRenderer) { /** @internal */ static _alphaCutoffProperty: ShaderProperty = ShaderProperty.getByName("renderer_MaskAlphaCutoff"); - private static _defaultColor: Color = new Color(1, 1, 1, 1); - @assignmentClone private _alphaCutoff: number = 0.5; @@ -51,13 +48,6 @@ export class Mask extends SpriteRenderable(UIRenderer) { } } - /** - * @internal - */ - override get color(): Color { - return Mask._defaultColor; - } - /** * @internal */ From 2c1cecc8f9e3c73feee5e31fe928b3f4426a0170 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Tue, 24 Mar 2026 23:19:43 +0800 Subject: [PATCH 8/9] feat: add examples --- examples/src/2d-01-sprite-mask.ts | 131 ++++++++++++++ examples/src/ui-01-basic.ts | 96 ++++++++++ examples/src/ui-02-mask.ts | 131 ++++++++++++++ examples/src/ui-03-rectmask.ts | 171 ++++++++++++++++++ .../core/src/2d/sprite/SpriteRenderable.ts | 37 ---- packages/core/src/2d/sprite/SpriteRenderer.ts | 35 +++- packages/core/src/2d/text/TextRenderable.ts | 39 ---- packages/core/src/2d/text/TextRenderer.ts | 35 +++- .../core/src/RenderPipeline/RenderQueue.ts | 4 +- .../src/RenderPipeline/SubRenderElement.ts | 2 + packages/core/src/ui/UIUtils.ts | 73 +++++++- packages/ui/src/component/UICanvas.ts | 2 +- packages/ui/src/component/advanced/Image.ts | 2 +- 13 files changed, 671 insertions(+), 87 deletions(-) create mode 100644 examples/src/2d-01-sprite-mask.ts create mode 100644 examples/src/ui-01-basic.ts create mode 100644 examples/src/ui-02-mask.ts create mode 100644 examples/src/ui-03-rectmask.ts diff --git a/examples/src/2d-01-sprite-mask.ts b/examples/src/2d-01-sprite-mask.ts new file mode 100644 index 0000000000..cbaf613e2b --- /dev/null +++ b/examples/src/2d-01-sprite-mask.ts @@ -0,0 +1,131 @@ +/** + * @title 2D 01 - SpriteMask 遮罩 + * @category 2D 教程 + */ +import { + Camera, + Color, + Entity, + Logger, + Script, + Sprite, + SpriteMask, + SpriteMaskInteraction, + SpriteMaskLayer, + SpriteRenderer, + Texture2D, + Vector3, + WebGLEngine +} from "@galacean/engine"; + +function createCircleTexture(engine: WebGLEngine, size: number): Texture2D { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = size; + const ctx = canvas.getContext("2d")!; + const center = size / 2; + ctx.beginPath(); + ctx.arc(center, center, center - 2, 0, Math.PI * 2); + ctx.fillStyle = "#ffffff"; + ctx.fill(); + const texture = new Texture2D(engine, size, size); + texture.setImageSource(canvas); + texture.generateMipmaps(); + return texture; +} + +function createColorTexture(engine: WebGLEngine, color: string, size: number = 128): Texture2D { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = size; + const ctx = canvas.getContext("2d")!; + ctx.fillStyle = color; + ctx.fillRect(0, 0, size, size); + const texture = new Texture2D(engine, size, size); + texture.setImageSource(canvas); + texture.generateMipmaps(); + return texture; +} + +class OscillateScript extends Script { + amplitude = 2; + speed = 1; + private _time = 0; + private _startX = 0; + + onStart() { + this._startX = this.entity.transform.position.x; + } + + onUpdate(dt: number) { + this._time += dt; + const x = this._startX + Math.sin(this._time * this.speed) * this.amplitude; + const pos = this.entity.transform.position; + this.entity.transform.setPosition(x, pos.y, pos.z); + } +} + +Logger.enable(); +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, 10); + cameraEntity.addComponent(Camera); + + const circleTexture = createCircleTexture(engine, 256); + const redTexture = createColorTexture(engine, "#e74c3c"); + const blueTexture = createColorTexture(engine, "#3498db"); + const greenTexture = createColorTexture(engine, "#2ecc71"); + + // --- SpriteMask (circle shape, oscillating) --- + const maskEntity = rootEntity.createChild("SpriteMask"); + maskEntity.transform.setPosition(0, 0, 0); + const mask = maskEntity.addComponent(SpriteMask); + mask.sprite = new Sprite(engine, circleTexture); + mask.alphaCutoff = 0.5; + mask.influenceLayers = SpriteMaskLayer.Layer0; + mask.width = 3; + mask.height = 3; + const oscillate = maskEntity.addComponent(OscillateScript); + oscillate.amplitude = 1.5; + oscillate.speed = 2; + + // --- Sprite: VisibleInsideMask --- + const insideEntity = rootEntity.createChild("InsideMask"); + insideEntity.transform.setPosition(-2, 0, 0); + const insideRenderer = insideEntity.addComponent(SpriteRenderer); + insideRenderer.sprite = new Sprite(engine, redTexture); + insideRenderer.width = 3; + insideRenderer.height = 3; + insideRenderer.maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + insideRenderer.maskLayer = SpriteMaskLayer.Layer0; + + // --- Sprite: VisibleOutsideMask --- + const outsideEntity = rootEntity.createChild("OutsideMask"); + outsideEntity.transform.setPosition(2, 0, 0); + const outsideRenderer = outsideEntity.addComponent(SpriteRenderer); + outsideRenderer.sprite = new Sprite(engine, blueTexture); + outsideRenderer.width = 3; + outsideRenderer.height = 3; + outsideRenderer.maskInteraction = SpriteMaskInteraction.VisibleOutsideMask; + outsideRenderer.maskLayer = SpriteMaskLayer.Layer0; + + // --- Sprite: No mask interaction (always visible) --- + const normalEntity = rootEntity.createChild("NoMask"); + normalEntity.transform.setPosition(0, -3, 0); + const normalRenderer = normalEntity.addComponent(SpriteRenderer); + normalRenderer.sprite = new Sprite(engine, greenTexture); + normalRenderer.width = 2; + normalRenderer.height = 2; + normalRenderer.color = new Color(1, 1, 1, 0.8); + + engine.run(); + + console.log("2D 01 - SpriteMask 遮罩"); + console.log("- 圆形 SpriteMask 左右摆动"); + console.log("- 红色: VisibleInsideMask (只在 mask 内可见)"); + console.log("- 蓝色: VisibleOutsideMask (只在 mask 外可见)"); + console.log("- 绿色: 无遮罩交互 (始终可见)"); + console.log("- 2D 遮罩使用 maskInteraction + maskLayer,与 UI 层级式遮罩不同"); +}); diff --git a/examples/src/ui-01-basic.ts b/examples/src/ui-01-basic.ts new file mode 100644 index 0000000000..ea669d978d --- /dev/null +++ b/examples/src/ui-01-basic.ts @@ -0,0 +1,96 @@ +/** + * @title UI 01 - 基础 UI 组件 + * @category UI 教程 + */ +import { + Camera, + Color, + Entity, + Font, + Logger, + Sprite, + Texture2D, + Vector2, + Vector3, + WebGLEngine +} from "@galacean/engine"; +import { CanvasRenderMode, Image, Text, UICanvas, UITransform } from "@galacean/engine-ui"; + +function createColorTexture(engine: WebGLEngine, r: number, g: number, b: number, a: number = 255): Texture2D { + const texture = new Texture2D(engine, 4, 4); + const data = new Uint8Array(4 * 4 * 4); + for (let i = 0; i < 64; i += 4) { + data[i] = r; + data[i + 1] = g; + data[i + 2] = b; + data[i + 3] = a; + } + texture.setPixelBuffer(data); + return texture; +} + +Logger.enable(); +WebGLEngine.create({ canvas: "canvas" }).then((engine) => { + engine.canvas.resizeByClientSize(); + const scene = engine.sceneManager.activeScene; + const rootEntity = scene.createRootEntity(); + + // Camera + const cameraEntity = rootEntity.createChild("camera"); + cameraEntity.transform.setPosition(0, 0, 10); + const camera = cameraEntity.addComponent(Camera); + + // UI Canvas (ScreenSpace Overlay) + const canvasEntity = rootEntity.createChild("Canvas"); + const canvas = canvasEntity.addComponent(UICanvas); + canvas.renderMode = CanvasRenderMode.ScreenSpaceOverlay; + canvas.referenceResolution = new Vector2(800, 600); + canvas.renderCamera = camera; + + // --- Background Panel --- + const panelEntity = canvasEntity.createChild("Panel"); + const panelTransform = panelEntity.transform; + panelTransform.size = new Vector2(400, 300); + const panelImage = panelEntity.addComponent(Image); + panelImage.sprite = new Sprite(engine, createColorTexture(engine, 40, 40, 60)); + panelImage.color = new Color(1, 1, 1, 0.9); + + // --- Title Text --- + const titleEntity = panelEntity.createChild("Title"); + const titleTransform = titleEntity.transform; + titleTransform.size = new Vector2(300, 50); + titleEntity.transform.setPosition(0, 100, 0); + const title = titleEntity.addComponent(Text); + title.text = "Galacean UI"; + title.fontSize = 32; + title.color = new Color(1, 1, 1, 1); + + // --- Colored Images --- + const colors = [ + { r: 231, g: 76, b: 60 }, + { r: 46, g: 204, b: 113 }, + { r: 52, g: 152, b: 219 } + ]; + const labels = ["Red", "Green", "Blue"]; + for (let i = 0; i < 3; i++) { + const itemEntity = panelEntity.createChild(`Item${i}`); + const itemTransform = itemEntity.transform; + itemTransform.size = new Vector2(100, 100); + itemEntity.transform.setPosition(-120 + i * 120, -20, 0); + + const img = itemEntity.addComponent(Image); + const { r, g, b } = colors[i]; + img.sprite = new Sprite(engine, createColorTexture(engine, r, g, b)); + + const labelEntity = itemEntity.createChild("Label"); + const labelTransform = labelEntity.transform; + labelTransform.size = new Vector2(100, 30); + labelEntity.transform.setPosition(0, -70, 0); + const label = labelEntity.addComponent(Text); + label.text = labels[i]; + label.fontSize = 18; + label.color = new Color(0.8, 0.8, 0.8, 1); + } + + engine.run(); +}); diff --git a/examples/src/ui-02-mask.ts b/examples/src/ui-02-mask.ts new file mode 100644 index 0000000000..6f66ecec70 --- /dev/null +++ b/examples/src/ui-02-mask.ts @@ -0,0 +1,131 @@ +/** + * @title UI 02 - Mask 遮罩 + * @category UI 教程 + */ +import { + Camera, + Color, + Entity, + Logger, + Script, + Sprite, + Texture2D, + Vector2, + Vector3, + WebGLEngine +} from "@galacean/engine"; +import { CanvasRenderMode, Image, Mask, UICanvas, UITransform } from "@galacean/engine-ui"; + +function createCircleTexture(engine: WebGLEngine, size: number): Texture2D { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = size; + const ctx = canvas.getContext("2d")!; + const center = size / 2; + const radius = center - 2; + ctx.beginPath(); + ctx.arc(center, center, radius, 0, Math.PI * 2); + ctx.fillStyle = "#ffffff"; + ctx.fill(); + const texture = new Texture2D(engine, size, size); + texture.setImageSource(canvas); + texture.generateMipmaps(); + return texture; +} + +function createGradientTexture(engine: WebGLEngine, size: number): Texture2D { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = size; + const ctx = canvas.getContext("2d")!; + const gradient = ctx.createLinearGradient(0, 0, size, size); + gradient.addColorStop(0, "#e74c3c"); + gradient.addColorStop(0.5, "#f39c12"); + gradient.addColorStop(1, "#2ecc71"); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, size, size); + const texture = new Texture2D(engine, size, size); + texture.setImageSource(canvas); + texture.generateMipmaps(); + return texture; +} + +function createCheckerTexture(engine: WebGLEngine, size: number): Texture2D { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = size; + const ctx = canvas.getContext("2d")!; + const tileSize = size / 8; + for (let x = 0; x < 8; x++) { + for (let y = 0; y < 8; y++) { + ctx.fillStyle = (x + y) % 2 === 0 ? "#3498db" : "#2c3e50"; + ctx.fillRect(x * tileSize, y * tileSize, tileSize, tileSize); + } + } + const texture = new Texture2D(engine, size, size); + texture.setImageSource(canvas); + texture.generateMipmaps(); + return texture; +} + +class RotateScript extends Script { + speed = 30; + onUpdate(dt: number) { + this.entity.transform.rotate(new Vector3(0, 0, this.speed * dt)); + } +} + +Logger.enable(); +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, 10); + const camera = cameraEntity.addComponent(Camera); + + const canvasEntity = rootEntity.createChild("Canvas"); + const canvas = canvasEntity.addComponent(UICanvas); + canvas.renderMode = CanvasRenderMode.ScreenSpaceOverlay; + canvas.referenceResolution = new Vector2(800, 600); + canvas.renderCamera = camera; + + const circleTexture = createCircleTexture(engine, 256); + const gradientTexture = createGradientTexture(engine, 256); + const checkerTexture = createCheckerTexture(engine, 256); + + // --- Example 1: Circle mask clipping a gradient --- + const mask1Entity = canvasEntity.createChild("CircleMask"); + const mask1Transform = mask1Entity.transform; + mask1Transform.size = new Vector2(200, 200); + mask1Entity.transform.setPosition(-200, 50, 0); + const mask1 = mask1Entity.addComponent(Mask); + mask1.sprite = new Sprite(engine, circleTexture); + + const content1 = mask1Entity.createChild("Content"); + const content1Transform = content1.transform; + content1Transform.size = new Vector2(250, 250); + const content1Image = content1.addComponent(Image); + content1Image.sprite = new Sprite(engine, gradientTexture); + content1.addComponent(RotateScript).speed = 20; + + // --- Example 2: Circle mask clipping a checker pattern --- + const mask2Entity = canvasEntity.createChild("CheckerMask"); + const mask2Transform = mask2Entity.transform; + mask2Transform.size = new Vector2(200, 200); + mask2Entity.transform.setPosition(200, 50, 0); + const mask2 = mask2Entity.addComponent(Mask); + mask2.sprite = new Sprite(engine, circleTexture); + + const content2 = mask2Entity.createChild("Content"); + const content2Transform = content2.transform; + content2Transform.size = new Vector2(300, 300); + const content2Image = content2.addComponent(Image); + content2Image.sprite = new Sprite(engine, checkerTexture); + content2.addComponent(RotateScript).speed = -15; + + engine.run(); + + console.log("UI 02 - Mask 遮罩"); + console.log("- 左: 圆形 Mask 裁剪渐变图片 (旋转)"); + console.log("- 右: 圆形 Mask 裁剪棋盘格 (旋转)"); + console.log("- 子节点自动被 Mask 裁剪,无需手动配置 maskInteraction"); +}); diff --git a/examples/src/ui-03-rectmask.ts b/examples/src/ui-03-rectmask.ts new file mode 100644 index 0000000000..c33b152b0d --- /dev/null +++ b/examples/src/ui-03-rectmask.ts @@ -0,0 +1,171 @@ +/** + * @title UI 03 - RectMask2D 矩形裁剪 + * @category UI 教程 + */ +import { + Camera, + Color, + Entity, + Logger, + Script, + Sprite, + Texture2D, + Vector2, + Vector3, + WebGLEngine +} from "@galacean/engine"; +import { CanvasRenderMode, Image, RectMask2D, Text, UICanvas, UITransform } from "@galacean/engine-ui"; + +function createColorTexture(engine: WebGLEngine, r: number, g: number, b: number): Texture2D { + const texture = new Texture2D(engine, 4, 4); + const data = new Uint8Array(4 * 4 * 4); + for (let i = 0; i < 64; i += 4) { + data[i] = r; + data[i + 1] = g; + data[i + 2] = b; + data[i + 3] = 255; + } + texture.setPixelBuffer(data); + return texture; +} + +function createGradientTexture(engine: WebGLEngine, size: number): Texture2D { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = size; + const ctx = canvas.getContext("2d")!; + const gradient = ctx.createLinearGradient(0, 0, size, size); + gradient.addColorStop(0, "#e74c3c"); + gradient.addColorStop(0.25, "#f39c12"); + gradient.addColorStop(0.5, "#2ecc71"); + gradient.addColorStop(0.75, "#3498db"); + gradient.addColorStop(1, "#9b59b6"); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, size, size); + const texture = new Texture2D(engine, size, size); + texture.setImageSource(canvas); + texture.generateMipmaps(); + return texture; +} + +/** Scrolls content up and down to show clipping effect */ +class ScrollScript extends Script { + speed = 60; + range = 100; + private _time = 0; + + onUpdate(dt: number) { + this._time += dt; + const y = Math.sin(this._time * this.speed * 0.02) * this.range; + const pos = this.entity.transform.position; + this.entity.transform.setPosition(pos.x, y, pos.z); + } +} + +Logger.enable(); +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, 10); + const camera = cameraEntity.addComponent(Camera); + + const canvasEntity = rootEntity.createChild("Canvas"); + const uiCanvas = canvasEntity.addComponent(UICanvas); + uiCanvas.renderMode = CanvasRenderMode.ScreenSpaceOverlay; + uiCanvas.referenceResolution = new Vector2(800, 600); + uiCanvas.renderCamera = camera; + + const solidTexture = createColorTexture(engine, 255, 255, 255); + const gradientTexture = createGradientTexture(engine, 256); + + // ========== Left: Hard clip (alphaClip = true) ========== + const leftFrame = canvasEntity.createChild("LeftFrame"); + (leftFrame.transform).size = new Vector2(240, 300); + leftFrame.transform.setPosition(-180, 20, 0); + const leftBg = leftFrame.addComponent(Image); + leftBg.sprite = new Sprite(engine, solidTexture); + leftBg.color = new Color(0.12, 0.14, 0.18, 1); + + const leftViewport = leftFrame.createChild("Viewport"); + (leftViewport.transform).size = new Vector2(200, 220); + leftViewport.transform.setPosition(0, -10, 0); + const leftRectMask = leftViewport.addComponent(RectMask2D); + leftRectMask.alphaClip = true; + + const leftContent = leftViewport.createChild("Content"); + (leftContent.transform).size = new Vector2(200, 600); + const leftScroll = leftContent.addComponent(ScrollScript); + leftScroll.range = 150; + leftScroll.speed = 40; + + const tileColors = [ + new Color(0.91, 0.3, 0.24, 1), + new Color(0.16, 0.5, 0.73, 1), + new Color(0.18, 0.8, 0.44, 1), + new Color(0.95, 0.61, 0.07, 1), + new Color(0.56, 0.27, 0.68, 1), + new Color(0.2, 0.6, 0.86, 1) + ]; + for (let i = 0; i < 6; i++) { + const tile = leftContent.createChild(`Tile${i}`); + (tile.transform).size = new Vector2(180, 70); + tile.transform.setPosition(0, 230 - i * 90, 0); + const tileImg = tile.addComponent(Image); + tileImg.sprite = new Sprite(engine, solidTexture); + tileImg.color = tileColors[i]; + + const label = tile.createChild("Label"); + (label.transform).size = new Vector2(160, 30); + const text = label.addComponent(Text); + text.text = `Item ${i + 1}`; + text.fontSize = 20; + text.color = new Color(1, 1, 1, 1); + } + + const leftTitle = leftFrame.createChild("Title"); + (leftTitle.transform).size = new Vector2(200, 30); + leftTitle.transform.setPosition(0, 125, 0); + const leftTitleText = leftTitle.addComponent(Text); + leftTitleText.text = "Hard Clip"; + leftTitleText.fontSize = 18; + leftTitleText.color = new Color(1, 1, 1, 0.9); + + // ========== Right: Soft clip (softness > 0) ========== + const rightFrame = canvasEntity.createChild("RightFrame"); + (rightFrame.transform).size = new Vector2(240, 300); + rightFrame.transform.setPosition(180, 20, 0); + const rightBg = rightFrame.addComponent(Image); + rightBg.sprite = new Sprite(engine, solidTexture); + rightBg.color = new Color(0.12, 0.14, 0.18, 1); + + const rightViewport = rightFrame.createChild("Viewport"); + (rightViewport.transform).size = new Vector2(200, 220); + rightViewport.transform.setPosition(0, -10, 0); + const rightRectMask = rightViewport.addComponent(RectMask2D); + rightRectMask.softness = new Vector2(30, 30); + + const rightContent = rightViewport.createChild("Content"); + (rightContent.transform).size = new Vector2(300, 300); + const rightImage = rightContent.addComponent(Image); + rightImage.sprite = new Sprite(engine, gradientTexture); + const rightScroll = rightContent.addComponent(ScrollScript); + rightScroll.range = 60; + rightScroll.speed = 30; + + const rightTitle = rightFrame.createChild("Title"); + (rightTitle.transform).size = new Vector2(200, 30); + rightTitle.transform.setPosition(0, 125, 0); + const rightTitleText = rightTitle.addComponent(Text); + rightTitleText.text = "Soft Clip"; + rightTitleText.fontSize = 18; + rightTitleText.color = new Color(1, 1, 1, 0.9); + + engine.run(); + + console.log("UI 03 - RectMask2D 矩形裁剪"); + console.log("- 左: alphaClip=true, 硬裁剪 (discard), 模拟滚动列表"); + console.log("- 右: softness=(30,30), 柔和边缘裁剪, 渐变图片滚动"); + console.log("- RectMask2D 基于层级自动裁剪子节点, 使用 shader 实现"); +}); diff --git a/packages/core/src/2d/sprite/SpriteRenderable.ts b/packages/core/src/2d/sprite/SpriteRenderable.ts index 92d691f067..eea54f3798 100644 --- a/packages/core/src/2d/sprite/SpriteRenderable.ts +++ b/packages/core/src/2d/sprite/SpriteRenderable.ts @@ -6,11 +6,9 @@ import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; import { SubRenderElement } from "../../RenderPipeline/SubRenderElement"; import { Renderer, RendererUpdateFlags } from "../../Renderer"; import { assignmentClone, ignoreClone } from "../../clone/CloneManager"; -import { SpriteMaskLayer } from "../../enums/SpriteMaskLayer"; import { Material } from "../../material"; import { ShaderProperty } from "../../shader/ShaderProperty"; import { Texture2D } from "../../texture"; -import { SpriteMaskInteraction } from "../enums/SpriteMaskInteraction"; import { ISpriteAssembler } from "../assembler/ISpriteAssembler"; import { SimpleSpriteAssembler } from "../assembler/SimpleSpriteAssembler"; import { SlicedSpriteAssembler } from "../assembler/SlicedSpriteAssembler"; @@ -48,10 +46,6 @@ export interface ISpriteRenderable { drawMode: SpriteDrawMode; tileMode: SpriteTileMode; tiledAdaptiveThreshold: number; - maskInteraction: SpriteMaskInteraction; - maskLayer: SpriteMaskLayer; - _maskInteraction: SpriteMaskInteraction; - _maskLayer: SpriteMaskLayer; _spriteData: SpritePrimitive; _getChunkManager(): PrimitiveChunkManager; _getDefaultSpriteMaterial(): Material; @@ -89,13 +83,6 @@ export function SpriteRenderable( @ignoreClone _spriteData: SpritePrimitive; - /** @internal */ - @assignmentClone - _maskInteraction: SpriteMaskInteraction = SpriteMaskInteraction.None; - /** @internal */ - @assignmentClone - _maskLayer: SpriteMaskLayer = SpriteMaskLayer.Layer0; - @ignoreClone private _drawMode: SpriteDrawMode; @ignoreClone @@ -167,30 +154,6 @@ export function SpriteRenderable( // ===== Public API (forwarding) ===== - /** - * The mask layer the renderer belongs to. - */ - get maskLayer(): SpriteMaskLayer { - return this._maskLayer; - } - - set maskLayer(value: SpriteMaskLayer) { - this._maskLayer = value; - } - - /** - * Interacts with the masks. - */ - get maskInteraction(): SpriteMaskInteraction { - return this._maskInteraction; - } - - set maskInteraction(value: SpriteMaskInteraction) { - if (this._maskInteraction !== value) { - this._maskInteraction = value; - } - } - /** * The Sprite to render. */ diff --git a/packages/core/src/2d/sprite/SpriteRenderer.ts b/packages/core/src/2d/sprite/SpriteRenderer.ts index e9929e8c21..2b6c15f2d7 100644 --- a/packages/core/src/2d/sprite/SpriteRenderer.ts +++ b/packages/core/src/2d/sprite/SpriteRenderer.ts @@ -4,11 +4,13 @@ import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManage import { RenderContext } from "../../RenderPipeline/RenderContext"; import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; import { Renderer, RendererUpdateFlags } from "../../Renderer"; -import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { assignmentClone, deepClone, ignoreClone } from "../../clone/CloneManager"; +import { SpriteMaskLayer } from "../../enums/SpriteMaskLayer"; import { Material } from "../../material"; import { ShaderProperty } from "../../shader/ShaderProperty"; import { Texture2D } from "../../texture"; import { SpriteDrawMode } from "../enums/SpriteDrawMode"; +import { SpriteMaskInteraction } from "../enums/SpriteMaskInteraction"; import { SpriteRenderable, SpriteRenderableFlags } from "./SpriteRenderable"; /** @@ -18,6 +20,13 @@ export class SpriteRenderer extends SpriteRenderable(Renderer) { /** @internal */ static _textureProperty: ShaderProperty = ShaderProperty.getByName("renderer_SpriteTexture"); + /** @internal */ + @assignmentClone + _maskInteraction: SpriteMaskInteraction = SpriteMaskInteraction.None; + /** @internal */ + @assignmentClone + _maskLayer: SpriteMaskLayer = SpriteMaskLayer.Layer0; + @deepClone private _color: Color = new Color(1, 1, 1, 1); @@ -131,6 +140,30 @@ export class SpriteRenderer extends SpriteRenderable(Renderer) { } } + /** + * The mask layer the sprite renderer belongs to. + */ + get maskLayer(): SpriteMaskLayer { + return this._maskLayer; + } + + set maskLayer(value: SpriteMaskLayer) { + this._maskLayer = value; + } + + /** + * Interacts with the masks. + */ + get maskInteraction(): SpriteMaskInteraction { + return this._maskInteraction; + } + + set maskInteraction(value: SpriteMaskInteraction) { + if (this._maskInteraction !== value) { + this._maskInteraction = value; + } + } + /** * @internal */ diff --git a/packages/core/src/2d/text/TextRenderable.ts b/packages/core/src/2d/text/TextRenderable.ts index cb6e6e689c..fb99f8d1f6 100644 --- a/packages/core/src/2d/text/TextRenderable.ts +++ b/packages/core/src/2d/text/TextRenderable.ts @@ -7,11 +7,9 @@ import { SubPrimitiveChunk } from "../../RenderPipeline/SubPrimitiveChunk"; import { SubRenderElement } from "../../RenderPipeline/SubRenderElement"; import { Renderer, RendererUpdateFlags } from "../../Renderer"; import { assignmentClone, ignoreClone } from "../../clone/CloneManager"; -import { SpriteMaskLayer } from "../../enums/SpriteMaskLayer"; import { Material } from "../../material"; import { ShaderProperty } from "../../shader"; import { Texture2D } from "../../texture"; -import { SpriteMaskInteraction } from "../enums/SpriteMaskInteraction"; import { FontStyle } from "../enums/FontStyle"; import { TextHorizontalAlignment, TextVerticalAlignment } from "../enums/TextAlignment"; import { OverflowMode } from "../enums/TextOverflow"; @@ -58,10 +56,6 @@ export interface ITextRenderable { verticalAlignment: TextVerticalAlignment; enableWrapping: boolean; overflowMode: OverflowMode; - maskInteraction: SpriteMaskInteraction; - maskLayer: SpriteMaskLayer; - _maskInteraction: SpriteMaskInteraction; - _maskLayer: SpriteMaskLayer; _subFont: SubFont; _getChunkManager(): PrimitiveChunkManager; _getSubFont(): SubFont; @@ -94,13 +88,6 @@ export function TextRenderable( private static _worldPositions = [new Vector3(), new Vector3(), new Vector3(), new Vector3()]; private static _charRenderInfos: CharRenderInfo[] = []; - /** @internal */ - @assignmentClone - _maskInteraction: SpriteMaskInteraction = SpriteMaskInteraction.None; - /** @internal */ - @assignmentClone - _maskLayer: SpriteMaskLayer = SpriteMaskLayer.Layer0; - @ignoreClone private _textChunks = Array(); /** @internal */ @@ -166,32 +153,6 @@ export function TextRenderable( return undefined; } - // ===== Mask properties ===== - - /** - * The mask layer the renderer belongs to. - */ - get maskLayer(): SpriteMaskLayer { - return this._maskLayer; - } - - set maskLayer(value: SpriteMaskLayer) { - this._maskLayer = value; - } - - /** - * Interacts with the masks. - */ - get maskInteraction(): SpriteMaskInteraction { - return this._maskInteraction; - } - - set maskInteraction(value: SpriteMaskInteraction) { - if (this._maskInteraction !== value) { - this._maskInteraction = value; - } - } - // ===== Text properties ===== get text(): string { diff --git a/packages/core/src/2d/text/TextRenderer.ts b/packages/core/src/2d/text/TextRenderer.ts index a7c4482985..f4f85232bb 100644 --- a/packages/core/src/2d/text/TextRenderer.ts +++ b/packages/core/src/2d/text/TextRenderer.ts @@ -3,9 +3,11 @@ import { Entity } from "../../Entity"; import { PrimitiveChunkManager } from "../../RenderPipeline/PrimitiveChunkManager"; import { RenderContext } from "../../RenderPipeline/RenderContext"; import { Renderer } from "../../Renderer"; -import { deepClone, ignoreClone } from "../../clone/CloneManager"; +import { assignmentClone, deepClone, ignoreClone } from "../../clone/CloneManager"; +import { SpriteMaskLayer } from "../../enums/SpriteMaskLayer"; import { ShaderData } from "../../shader"; import { ShaderDataGroup } from "../../shader/enums/ShaderDataGroup"; +import { SpriteMaskInteraction } from "../enums/SpriteMaskInteraction"; import { Material } from "../../material"; import { TextChunk, TextRenderable, TextRenderableFlags } from "./TextRenderable"; @@ -13,6 +15,13 @@ import { TextChunk, TextRenderable, TextRenderableFlags } from "./TextRenderable * Renders a text for 2D graphics. */ export class TextRenderer extends TextRenderable(Renderer) { + /** @internal */ + @assignmentClone + _maskInteraction: SpriteMaskInteraction = SpriteMaskInteraction.None; + /** @internal */ + @assignmentClone + _maskLayer: SpriteMaskLayer = SpriteMaskLayer.Layer0; + @deepClone private _color = new Color(1, 1, 1, 1); @@ -60,6 +69,30 @@ export class TextRenderer extends TextRenderable(Renderer) { } } + /** + * The mask layer the text renderer belongs to. + */ + get maskLayer(): SpriteMaskLayer { + return this._maskLayer; + } + + set maskLayer(value: SpriteMaskLayer) { + this._maskLayer = value; + } + + /** + * Interacts with the masks. + */ + get maskInteraction(): SpriteMaskInteraction { + return this._maskInteraction; + } + + set maskInteraction(value: SpriteMaskInteraction) { + if (this._maskInteraction !== value) { + this._maskInteraction = value; + } + } + constructor(entity: Entity) { super(entity); this._initTextRenderable(); diff --git a/packages/core/src/RenderPipeline/RenderQueue.ts b/packages/core/src/RenderPipeline/RenderQueue.ts index 89aab2386c..30dc08ab76 100644 --- a/packages/core/src/RenderPipeline/RenderQueue.ts +++ b/packages/core/src/RenderPipeline/RenderQueue.ts @@ -118,8 +118,8 @@ export class RenderQueue { } let renderState = shaderPass._renderState; - if (needMaskType) { - // Mask don't care render queue type + if (needMaskType || uiStencilDepth > 0) { + // Mask and UI stencil elements don't care about render queue type if (!renderState) { renderState = renderStates[j]; } diff --git a/packages/core/src/RenderPipeline/SubRenderElement.ts b/packages/core/src/RenderPipeline/SubRenderElement.ts index f010e8cfd7..aea7b4ea8f 100644 --- a/packages/core/src/RenderPipeline/SubRenderElement.ts +++ b/packages/core/src/RenderPipeline/SubRenderElement.ts @@ -40,6 +40,8 @@ export class SubRenderElement implements IPoolElement { this.subPrimitive = subPrimitive; this.texture = texture; this.subChunk = subChunk; + this.uiStencilDepth = 0; + this.uiStencilOp = 0; } dispose(): void { diff --git a/packages/core/src/ui/UIUtils.ts b/packages/core/src/ui/UIUtils.ts index c57c8bdf1f..ad2a8e517a 100644 --- a/packages/core/src/ui/UIUtils.ts +++ b/packages/core/src/ui/UIUtils.ts @@ -1,14 +1,21 @@ -import { Matrix, Vector4 } from "@galacean/engine-math"; +import { Color, Matrix, Vector4 } from "@galacean/engine-math"; import { Camera } from "../Camera"; import { Engine } from "../Engine"; import { Layer } from "../Layer"; +import { Blitter } from "../RenderPipeline/Blitter"; import { RenderQueue } from "../RenderPipeline"; import { ContextRendererUpdateFlag } from "../RenderPipeline/RenderContext"; import { Scene } from "../Scene"; import { VirtualCamera } from "../VirtualCamera"; import { EngineObject } from "../base"; -import { RenderQueueType, ShaderData, ShaderDataGroup, ShaderMacro } from "../shader"; +import { CameraClearFlags } from "../enums/CameraClearFlags"; +import { Material } from "../material"; +import { RenderQueueType, Shader, ShaderData, ShaderDataGroup, ShaderMacro } from "../shader"; +import { BlendFactor } from "../shader/enums/BlendFactor"; import { ShaderMacroCollection } from "../shader/ShaderMacroCollection"; +import { RenderTarget } from "../texture/RenderTarget"; +import { Texture2D } from "../texture/Texture2D"; +import { TextureFormat } from "../texture/enums/TextureFormat"; import { DisorderedArray } from "../utils/DisorderedArray"; import { IUICanvas } from "./IUICanvas"; @@ -19,6 +26,9 @@ export class UIUtils { private static _virtualCamera: VirtualCamera; private static _viewport: Vector4; private static _overlayCamera: OverlayCamera; + private static _overlayRT: RenderTarget; + private static _overlayBlitMaterial: Material; + private static _clearColor = new Color(0, 0, 0, 0); static renderOverlay(engine: Engine, scene: Scene, uiCanvases: DisorderedArray): void { engine._macroCollection.enable(UIUtils._shouldSRGBCorrect); @@ -31,17 +41,24 @@ export class UIUtils { camera.engine = engine; camera.scene = scene; renderContext.camera = camera as unknown as Camera; + + const { width, height } = canvas; const { elements: projectE } = virtualCamera.projectionMatrix; const { elements: viewE } = virtualCamera.viewMatrix; - (projectE[0] = 2 / canvas.width), (projectE[5] = 2 / canvas.height), (projectE[10] = 0); - renderContext.setRenderTarget(null, viewport, 0); + (projectE[0] = 2 / width), (projectE[5] = 2 / height), (projectE[10] = 0); + + // Use an intermediate RT with stencil so that UI Mask (stencil-based) works + const overlayRT = UIUtils._getOverlayRT(engine, width, height); + renderContext.setRenderTarget(overlayRT, viewport, 0); + rhi.clearRenderTarget(engine, CameraClearFlags.All, UIUtils._clearColor); + for (let i = 0, n = uiCanvases.length; i < n; i++) { const uiCanvas = uiCanvases.get(i); if (uiCanvas) { const { position } = uiCanvas.entity.transform; (viewE[12] = -position.x), (viewE[13] = -position.y); Matrix.multiply(virtualCamera.projectionMatrix, virtualCamera.viewMatrix, virtualCamera.viewProjectionMatrix); - renderContext.applyVirtualCamera(virtualCamera, false); + renderContext.applyVirtualCamera(virtualCamera, true); uiRenderQueue.rendererUpdateFlag |= ContextRendererUpdateFlag.ProjectionMatrix; uiCanvas._prepareRender(renderContext); uiRenderQueue.pushRenderElement(uiCanvas._renderElement); @@ -52,9 +69,55 @@ export class UIUtils { engine._renderCount++; } } + + // Blit overlay RT to default framebuffer with premultiplied alpha blending + Blitter.blitTexture( + engine, + overlayRT.getColorTexture(0) as Texture2D, + null, + 0, + viewport, + UIUtils._getOverlayBlitMaterial(engine) + ); + renderContext.camera = null; engine._macroCollection.disable(UIUtils._shouldSRGBCorrect); } + + private static _getOverlayRT(engine: Engine, width: number, height: number): RenderTarget { + let rt = UIUtils._overlayRT; + if (!rt || rt.width !== width || rt.height !== height) { + if (rt) { + rt.getColorTexture(0).destroy(); + rt.destroy(); + } + const colorTexture = new Texture2D(engine, width, height, TextureFormat.R8G8B8A8, false); + colorTexture.isGCIgnored = true; + rt = new RenderTarget(engine, width, height, colorTexture, TextureFormat.Depth24Stencil8); + rt.isGCIgnored = true; + UIUtils._overlayRT = rt; + } + return rt; + } + + private static _getOverlayBlitMaterial(engine: Engine): Material { + let material = UIUtils._overlayBlitMaterial; + if (!material) { + material = new Material(engine, Shader.find("blit")); + material.isGCIgnored = true; + const renderState = material.renderState; + renderState.depthState.enabled = false; + renderState.depthState.writeEnabled = false; + const target = renderState.blendState.targetBlendState; + target.enabled = true; + target.sourceColorBlendFactor = BlendFactor.One; + target.destinationColorBlendFactor = BlendFactor.OneMinusSourceAlpha; + target.sourceAlphaBlendFactor = BlendFactor.One; + target.destinationAlphaBlendFactor = BlendFactor.OneMinusSourceAlpha; + UIUtils._overlayBlitMaterial = material; + } + return material; + } } class OverlayCamera { diff --git a/packages/ui/src/component/UICanvas.ts b/packages/ui/src/component/UICanvas.ts index b998b8c91f..c2500f0034 100644 --- a/packages/ui/src/component/UICanvas.ts +++ b/packages/ui/src/component/UICanvas.ts @@ -532,7 +532,7 @@ export class UICanvas extends Component implements IElement { tempGroupAbleList[groupAbleCount++] = component; } component._setRectMasks(tempRectMaskList, rectMaskCount); - // Mask's stencilDepth is set AFTER incrementing below + component._uiStencilDepth = stencilDepth + 1; } else if (component instanceof UIRenderer) { renderers[depth] = component; ++depth; diff --git a/packages/ui/src/component/advanced/Image.ts b/packages/ui/src/component/advanced/Image.ts index ac19c057c8..3bded132d2 100644 --- a/packages/ui/src/component/advanced/Image.ts +++ b/packages/ui/src/component/advanced/Image.ts @@ -33,7 +33,7 @@ export class Image extends SpriteRenderable(UIRenderer) { // ===== Abstract implementations ===== /** @internal */ - _getSpriteColor() { + override _getSpriteColor() { return this._color; } From cc36ac3dc40d621cb5bdb34951812471a757e982 Mon Sep 17 00:00:00 2001 From: cptbtptpbcptdtptp Date: Tue, 7 Apr 2026 15:52:20 +0800 Subject: [PATCH 9/9] refactor(2d): move common renderable properties to base classes Consolidate width/height/flipX/flipY and related logic from SpriteMask, SpriteRenderer, Image, Mask, and Text into SpriteRenderable and UIRenderer base classes to reduce duplication. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/2d/sprite/SpriteMask.ts | 171 +++++++----------- .../core/src/2d/sprite/SpriteRenderable.ts | 156 +++++++++++----- packages/core/src/2d/sprite/SpriteRenderer.ts | 133 +++++--------- packages/core/src/2d/text/TextRenderable.ts | 65 +++---- packages/core/src/2d/text/TextRenderer.ts | 20 +- packages/ui/src/component/UIRenderer.ts | 106 +++++++++++ packages/ui/src/component/advanced/Image.ts | 53 +----- packages/ui/src/component/advanced/Mask.ts | 41 +---- packages/ui/src/component/advanced/Text.ts | 26 +-- 9 files changed, 382 insertions(+), 389 deletions(-) diff --git a/packages/core/src/2d/sprite/SpriteMask.ts b/packages/core/src/2d/sprite/SpriteMask.ts index ef62794311..1f1d904e6e 100644 --- a/packages/core/src/2d/sprite/SpriteMask.ts +++ b/packages/core/src/2d/sprite/SpriteMask.ts @@ -13,7 +13,8 @@ import { SpriteMaskLayer } from "../../enums/SpriteMaskLayer"; import { Material } from "../../material"; import { ShaderProperty } from "../../shader/ShaderProperty"; import { Texture2D } from "../../texture"; -import { SpriteRenderable } from "./SpriteRenderable"; +import { SpriteDrawMode } from "../enums/SpriteDrawMode"; +import { SpriteRenderable, SpriteRenderableFlags } from "./SpriteRenderable"; import { SpriteMaskUtils } from "./SpriteMaskUtils"; /** @@ -35,69 +36,54 @@ export class SpriteMask extends SpriteRenderable(Renderer) { @ignoreClone _maskIndex: number = -1; - @ignoreClone - private _automaticWidth: number = 0; - @ignoreClone - private _automaticHeight: number = 0; - @assignmentClone - private _customWidth: number = undefined; - @assignmentClone - private _customHeight: number = undefined; - @assignmentClone - private _flipX: boolean = false; - @assignmentClone - private _flipY: boolean = false; - @ignoreClone - private _autoSizeDirty: boolean = true; - @assignmentClone private _alphaCutoff: number = 0.5; /** - * Render width (in world coordinates). - * - * @remarks - * If width is set, return the set value, - * otherwise return `SpriteMask.sprite.width`. + * The minimum alpha value used by the mask to select the area of influence defined over the mask's sprite. Value between 0 and 1. */ - get width(): number { - if (this._customWidth !== undefined) { - return this._customWidth; - } - if (this._autoSizeDirty) { - this._calDefaultSize(); + get alphaCutoff(): number { + return this._alphaCutoff; + } + + set alphaCutoff(value: number) { + if (this._alphaCutoff !== value) { + this._alphaCutoff = value; + this.shaderData.setFloat(SpriteMask._alphaCutoffProperty, value); } - return this._automaticWidth; + } + + /** + * Render width. If set, uses custom value; otherwise uses sprite's natural width. + */ + get width(): number { + return this._getWidth(); } set width(value: number) { if (this._customWidth !== value) { this._customWidth = value; - this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; + this._dirtyUpdateFlag |= + this.drawMode === SpriteDrawMode.Tiled + ? SpriteRenderableFlags.WorldVolumeUVAndColor + : RendererUpdateFlags.WorldVolume; } } /** - * Render height (in world coordinates). - * - * @remarks - * If height is set, return the set value, - * otherwise return `SpriteMask.sprite.height`. + * Render height. If set, uses custom value; otherwise uses sprite's natural height. */ get height(): number { - if (this._customHeight !== undefined) { - return this._customHeight; - } - if (this._autoSizeDirty) { - this._calDefaultSize(); - } - return this._automaticHeight; + return this._getHeight(); } set height(value: number) { if (this._customHeight !== value) { this._customHeight = value; - this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; + this._dirtyUpdateFlag |= + this.drawMode === SpriteDrawMode.Tiled + ? SpriteRenderableFlags.WorldVolumeUVAndColor + : RendererUpdateFlags.WorldVolume; } } @@ -129,20 +115,6 @@ export class SpriteMask extends SpriteRenderable(Renderer) { } } - /** - * The minimum alpha value used by the mask to select the area of influence defined over the mask's sprite. Value between 0 and 1. - */ - get alphaCutoff(): number { - return this._alphaCutoff; - } - - set alphaCutoff(value: number) { - if (this._alphaCutoff !== value) { - this._alphaCutoff = value; - this.shaderData.setFloat(SpriteMask._alphaCutoffProperty, value); - } - } - /** * @internal */ @@ -156,6 +128,43 @@ export class SpriteMask extends SpriteRenderable(Renderer) { // ===== SpriteRenderable abstract implementations ===== + /** @internal */ + override _getWidth(): number { + if (this._customWidth !== undefined) { + return this._customWidth; + } + if (this._autoSizeDirty) { + this._calDefaultSize(); + } + return this._automaticWidth; + } + + /** @internal */ + override _getHeight(): number { + if (this._customHeight !== undefined) { + return this._customHeight; + } + if (this._autoSizeDirty) { + this._calDefaultSize(); + } + return this._automaticHeight; + } + + /** @internal */ + override _getAlpha(): number { + return 1; + } + + /** @internal */ + override _getPivot(): Vector2 { + return this.sprite?.pivot; + } + + /** @internal */ + override _getReferenceResolutionPerUnit(): number | undefined { + return undefined; + } + /** @internal */ override _getChunkManager(): PrimitiveChunkManager { return this.engine._batcherManager.primitiveChunkManagerMask; @@ -182,39 +191,6 @@ export class SpriteMask extends SpriteRenderable(Renderer) { renderElement.addSubRenderElement(subRenderElement); } - /** @internal */ - override _getSpriteWidth(): number { - return this.width; - } - - /** @internal */ - override _getSpriteHeight(): number { - return this.height; - } - - /** @internal */ - override _getSpriteFlipX(): boolean { - return this._flipX; - } - - /** @internal */ - override _getSpriteFlipY(): boolean { - return this._flipY; - } - - /** @internal */ - override _onSpriteSizeChanged(): void { - this._autoSizeDirty = true; - if (this._customWidth === undefined || this._customHeight === undefined) { - this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; - } - } - - /** @internal */ - override _onSpritePivotChanged(): void { - this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; - } - // ===== Mask-specific overrides ===== /** @internal */ @@ -251,8 +227,8 @@ export class SpriteMask extends SpriteRenderable(Renderer) { this.width, this.height, sprite.pivot, - this._getSpriteFlipX(), - this._getSpriteFlipY(), + this.flipX, + this.flipY, this.alphaCutoff ); } @@ -261,15 +237,4 @@ export class SpriteMask extends SpriteRenderable(Renderer) { super._onDestroy(); this._renderElement = null; } - - private _calDefaultSize(): void { - const sprite = this.sprite; - if (sprite) { - this._automaticWidth = sprite.width; - this._automaticHeight = sprite.height; - } else { - this._automaticWidth = this._automaticHeight = 0; - } - this._autoSizeDirty = false; - } -} +} \ No newline at end of file diff --git a/packages/core/src/2d/sprite/SpriteRenderable.ts b/packages/core/src/2d/sprite/SpriteRenderable.ts index eea54f3798..31cf76e645 100644 --- a/packages/core/src/2d/sprite/SpriteRenderable.ts +++ b/packages/core/src/2d/sprite/SpriteRenderable.ts @@ -47,15 +47,31 @@ export interface ISpriteRenderable { tileMode: SpriteTileMode; tiledAdaptiveThreshold: number; _spriteData: SpritePrimitive; + /** @internal */ + _customWidth?: number; + /** @internal */ + _customHeight?: number; + /** @internal */ + _automaticWidth: number; + /** @internal */ + _automaticHeight: number; + /** @internal */ + _autoSizeDirty: boolean; + /** @internal */ + _flipX: boolean; + /** @internal */ + _flipY: boolean; + /** @internal */ + _calDefaultSize(): void; _getChunkManager(): PrimitiveChunkManager; _getDefaultSpriteMaterial(): Material; - _getSpriteColor(): Color | null; - _getSpriteAlpha(): number; - _getSpriteWidth(): number; - _getSpriteHeight(): number; - _getSpritePivot(): Vector2; - _getSpriteFlipX(): boolean; - _getSpriteFlipY(): boolean; + _getColor(): Color | null; + _getAlpha(): number; + _getWidth(): number; + _getHeight(): number; + _getPivot(): Vector2; + _getFlipX(): boolean; + _getFlipY(): boolean; _getReferenceResolutionPerUnit(): number | undefined; _onSpriteSizeChanged(): void; _onSpritePivotChanged(): void; @@ -92,6 +108,30 @@ export function SpriteRenderable( @assignmentClone private _tiledAdaptiveThreshold: number = 0.5; + // ===== Size management (optional, for 2D sprites) ===== + + /** @internal */ + @ignoreClone + protected _customWidth?: number; + /** @internal */ + @ignoreClone + protected _customHeight?: number; + /** @internal */ + @ignoreClone + protected _automaticWidth: number = 0; + /** @internal */ + @ignoreClone + protected _automaticHeight: number = 0; + /** @internal */ + @ignoreClone + protected _autoSizeDirty: boolean = true; + /** @internal */ + @assignmentClone + protected _flipX: boolean = false; + /** @internal */ + @assignmentClone + protected _flipY: boolean = false; + // ===== Abstract methods: host MUST implement ===== /** Which PrimitiveChunkManager to allocate vertex data from. */ @@ -108,49 +148,69 @@ export function SpriteRenderable( texture: Texture2D ): void; - /** The sprite width for layout. */ - abstract _getSpriteWidth(): number; + // ===== Abstract methods: host MUST implement (avoids MRO shadowing) ===== + + /** Get width for rendering. 2D hosts use custom/automatic size; UI hosts use UITransform.size. */ + abstract _getWidth(): number; + + /** Get height for rendering. 2D hosts use custom/automatic size; UI hosts use UITransform.size. */ + abstract _getHeight(): number; + + /** Final alpha multiplier. 2D hosts return 1; UI hosts return globalAlpha. */ + abstract _getAlpha(): number; - /** The sprite height for layout. */ - abstract _getSpriteHeight(): number; + /** Pivot for rendering. 2D hosts return sprite pivot; UI hosts return UITransform pivot. */ + abstract _getPivot(): Vector2; + + /** Reference resolution per unit. 2D hosts return undefined; UI hosts return canvas value. */ + abstract _getReferenceResolutionPerUnit(): number | undefined; // ===== Methods with defaults: host CAN override ===== - /** Sprite color for vertex coloring. Default: null (no color, for masks). */ - _getSpriteColor(): Color | null { + /** Color for vertex coloring. Default: null (no color, for masks). */ + _getColor(): Color | null { return null; } - /** Final alpha multiplier. Default: 1. UI hosts override to globalAlpha. */ - _getSpriteAlpha(): number { - return 1; - } - - /** Sprite pivot. Default: sprite's own pivot. */ - _getSpritePivot(): Vector2 { - return this._spriteData.sprite?.pivot; + /** Whether to flip X. Default: returns internal _flipX. */ + _getFlipX(): boolean { + return this._flipX; } - /** Whether to flip X. Default: false. */ - _getSpriteFlipX(): boolean { - return false; + /** Whether to flip Y. Default: returns internal _flipY. */ + _getFlipY(): boolean { + return this._flipY; } - /** Whether to flip Y. Default: false. */ - _getSpriteFlipY(): boolean { - return false; + /** Called when sprite size changes. Default: mark auto size dirty. */ + _onSpriteSizeChanged(): void { + this._autoSizeDirty = true; + if (this._customWidth === undefined || this._customHeight === undefined) { + this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; + } } - /** Reference resolution per unit. Default: undefined. */ - _getReferenceResolutionPerUnit(): number | undefined { - return undefined; + /** Called when sprite pivot changes. Default: mark world volume dirty. */ + _onSpritePivotChanged(): void { + this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; } - /** Called when sprite size changes. Host can override to mark dirty flags. */ - _onSpriteSizeChanged(): void {} + // ===== Internal helpers ===== - /** Called when sprite pivot changes. Host can override to mark dirty flags. */ - _onSpritePivotChanged(): void {} + /** + * Calculate default size from sprite. + * @internal + */ + protected _calDefaultSize(): void { + const sprite = this._spriteData.sprite; + if (sprite) { + this._automaticWidth = sprite.width; + this._automaticHeight = sprite.height; + } else { + this._automaticWidth = this._automaticHeight = 0; + } + this._autoSizeDirty = false; + } // ===== Public API (forwarding) ===== @@ -281,11 +341,11 @@ export function SpriteRenderable( this._spriteData, this._getChunkManager(), this._transformEntity.transform.worldMatrix, - this._getSpriteWidth(), - this._getSpriteHeight(), - this._getSpritePivot(), - this._getSpriteFlipX(), - this._getSpriteFlipY(), + this._getWidth(), + this._getHeight(), + this._getPivot(), + this._getFlipX(), + this._getFlipY(), this._bounds, this._getReferenceResolutionPerUnit(), this._tileMode, @@ -300,8 +360,8 @@ export function SpriteRenderable( protected override _render(context: RenderContext): void { const sprite = this._spriteData.sprite; - const width = this._getSpriteWidth(); - const height = this._getSpriteHeight(); + const width = this._getWidth(); + const height = this._getHeight(); if (!sprite?.texture || !width || !height) { return; } @@ -315,8 +375,8 @@ export function SpriteRenderable( material = this._getDefaultSpriteMaterial(); } - const color = this._getSpriteColor(); - const alpha = this._getSpriteAlpha(); + const color = this._getColor(); + const alpha = this._getAlpha(); if (color && color.a * alpha <= 0) { return; } @@ -327,11 +387,11 @@ export function SpriteRenderable( this._spriteData, this._getChunkManager(), this._transformEntity.transform.worldMatrix, - this._getSpriteWidth(), - this._getSpriteHeight(), - this._getSpritePivot(), - this._getSpriteFlipX(), - this._getSpriteFlipY(), + this._getWidth(), + this._getHeight(), + this._getPivot(), + this._getFlipX(), + this._getFlipY(), this._bounds, this._getReferenceResolutionPerUnit(), this._tileMode, diff --git a/packages/core/src/2d/sprite/SpriteRenderer.ts b/packages/core/src/2d/sprite/SpriteRenderer.ts index 2b6c15f2d7..713393cf5b 100644 --- a/packages/core/src/2d/sprite/SpriteRenderer.ts +++ b/packages/core/src/2d/sprite/SpriteRenderer.ts @@ -30,21 +30,6 @@ export class SpriteRenderer extends SpriteRenderable(Renderer) { @deepClone private _color: Color = new Color(1, 1, 1, 1); - @ignoreClone - private _customWidth: number = undefined; - @ignoreClone - private _customHeight: number = undefined; - @ignoreClone - private _automaticWidth: number = 0; - @ignoreClone - private _automaticHeight: number = 0; - @ignoreClone - private _autoSizeDirty: boolean = true; - @ignoreClone - private _flipX: boolean = false; - @ignoreClone - private _flipY: boolean = false; - /** * Rendering color for the Sprite graphic. */ @@ -59,20 +44,10 @@ export class SpriteRenderer extends SpriteRenderable(Renderer) { } /** - * Render width (in world coordinates). - * - * @remarks - * If width is set, return the set value, - * otherwise return `SpriteRenderer.sprite.width`. + * Render width. If set, uses custom value; otherwise uses sprite's natural width. */ get width(): number { - if (this._customWidth !== undefined) { - return this._customWidth; - } - if (this._autoSizeDirty) { - this._calDefaultSize(); - } - return this._automaticWidth; + return this._getWidth(); } set width(value: number) { @@ -86,20 +61,10 @@ export class SpriteRenderer extends SpriteRenderable(Renderer) { } /** - * Render height (in world coordinates). - * - * @remarks - * If height is set, return the set value, - * otherwise return `SpriteRenderer.sprite.height`. + * Render height. If set, uses custom value; otherwise uses sprite's natural height. */ get height(): number { - if (this._customHeight !== undefined) { - return this._customHeight; - } - if (this._autoSizeDirty) { - this._calDefaultSize(); - } - return this._automaticHeight; + return this._getHeight(); } set height(value: number) { @@ -177,10 +142,47 @@ export class SpriteRenderer extends SpriteRenderable(Renderer) { // ===== Abstract implementations ===== /** @internal */ - override _getSpriteColor(): Color { + override _getColor(): Color { return this._color; } + /** @internal */ + override _getWidth(): number { + if (this._customWidth !== undefined) { + return this._customWidth; + } + if (this._autoSizeDirty) { + this._calDefaultSize(); + } + return this._automaticWidth; + } + + /** @internal */ + override _getHeight(): number { + if (this._customHeight !== undefined) { + return this._customHeight; + } + if (this._autoSizeDirty) { + this._calDefaultSize(); + } + return this._automaticHeight; + } + + /** @internal */ + override _getAlpha(): number { + return 1; + } + + /** @internal */ + override _getPivot(): Vector2 { + return this.sprite?.pivot; + } + + /** @internal */ + override _getReferenceResolutionPerUnit(): number | undefined { + return undefined; + } + /** @internal */ override _getChunkManager(): PrimitiveChunkManager { return this.engine._batcherManager.primitiveChunkManager2D; @@ -208,59 +210,10 @@ export class SpriteRenderer extends SpriteRenderable(Renderer) { camera._renderPipeline.pushRenderElement(context, renderElement); } - /** @internal */ - override _getSpriteWidth(): number { - return this.width; - } - - /** @internal */ - override _getSpriteHeight(): number { - return this.height; - } - - /** @internal */ - override _getSpritePivot(): Vector2 { - return this.sprite?.pivot; - } - - /** @internal */ - override _getSpriteFlipX(): boolean { - return this._flipX; - } - - /** @internal */ - override _getSpriteFlipY(): boolean { - return this._flipY; - } - - /** @internal */ - override _onSpriteSizeChanged(): void { - this._autoSizeDirty = true; - if (this._customWidth === undefined || this._customHeight === undefined) { - this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; - } - } - - /** @internal */ - override _onSpritePivotChanged(): void { - this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; - } - // ===== Private ===== - private _calDefaultSize(): void { - const sprite = this.sprite; - if (sprite) { - this._automaticWidth = sprite.width; - this._automaticHeight = sprite.height; - } else { - this._automaticWidth = this._automaticHeight = 0; - } - this._autoSizeDirty = false; - } - @ignoreClone private _onColorChanged(): void { this._dirtyUpdateFlag |= SpriteRenderableFlags.Color; } -} +} \ No newline at end of file diff --git a/packages/core/src/2d/text/TextRenderable.ts b/packages/core/src/2d/text/TextRenderable.ts index fb99f8d1f6..bf95e99e0b 100644 --- a/packages/core/src/2d/text/TextRenderable.ts +++ b/packages/core/src/2d/text/TextRenderable.ts @@ -59,12 +59,12 @@ export interface ITextRenderable { _subFont: SubFont; _getChunkManager(): PrimitiveChunkManager; _getSubFont(): SubFont; - _getTextWidth(): number; - _getTextHeight(): number; - _getTextPivotX(): number; - _getTextPivotY(): number; - _getTextReferenceResolutionPerUnit(): number | undefined; - _getTextAlpha(): number; + _getWidth(): number; + _getHeight(): number; + _getPivotX(): number; + _getPivotY(): number; + _getReferenceResolutionPerUnit(): number | undefined; + _getAlpha(): number; _submitText(context: RenderContext, material: Material): void; _isTextHostInvisible(): boolean; _isContainDirtyFlag(type: number): boolean; @@ -123,34 +123,29 @@ export function TextRenderable( abstract _submitText(context: RenderContext, material: Material): void; /** The text layout width. */ - abstract _getTextWidth(): number; + abstract _getWidth(): number; /** The text layout height. */ - abstract _getTextHeight(): number; + abstract _getHeight(): number; - // ===== Methods with defaults ===== + // ===== Abstract methods: host MUST implement (avoids MRO shadowing) ===== - _getTextAlpha(): number { - return 1; - } + /** Final alpha multiplier. 2D hosts return 1; UI hosts return globalAlpha. */ + abstract _getAlpha(): number; - _isTextHostInvisible(): boolean { - return false; - } + /** Text pivot X. 2D hosts return 0.5; UI hosts return UITransform pivot. */ + abstract _getPivotX(): number; - /** Text pivot X. Default: 0.5. */ - _getTextPivotX(): number { - return 0.5; - } + /** Text pivot Y. 2D hosts return 0.5; UI hosts return UITransform pivot. */ + abstract _getPivotY(): number; - /** Text pivot Y. Default: 0.5. */ - _getTextPivotY(): number { - return 0.5; - } + /** Reference resolution per unit. 2D hosts return undefined; UI hosts return canvas value. */ + abstract _getReferenceResolutionPerUnit(): number | undefined; + + // ===== Methods with defaults ===== - /** Reference resolution per unit. Default: undefined (no scaling). */ - _getTextReferenceResolutionPerUnit(): number | undefined { - return undefined; + _isTextHostInvisible(): boolean { + return false; } // ===== Text properties ===== @@ -409,8 +404,8 @@ export function TextRenderable( // ===== Private ===== private _isTextNoVisible(): boolean { - const textWidth = this._getTextWidth(); - const textHeight = this._getTextHeight(); + const textWidth = this._getWidth(); + const textHeight = this._getHeight(); return ( !this._font || this._text === "" || @@ -480,7 +475,7 @@ export function TextRenderable( private _updateColor(): void { const { r, g, b, a } = this.color; - const finalAlpha = a * this._getTextAlpha(); + const finalAlpha = a * this._getAlpha(); const textChunks = this._textChunks; for (let i = 0, n = textChunks.length; i < n; ++i) { const subChunk = textChunks[i].subChunk; @@ -497,11 +492,11 @@ export function TextRenderable( } private _updateLocalData(): void { - let rendererWidth = this._getTextWidth(); - let rendererHeight = this._getTextHeight(); - const pivotX = this._getTextPivotX(); - const pivotY = this._getTextPivotY(); - const resPerUnit = this._getTextReferenceResolutionPerUnit(); + let rendererWidth = this._getWidth(); + let rendererHeight = this._getHeight(); + const pivotX = this._getPivotX(); + const pivotY = this._getPivotY(); + const resPerUnit = this._getReferenceResolutionPerUnit(); const pixelsPerUnit = resPerUnit ? Engine._pixelsPerUnit / resPerUnit : Engine._pixelsPerUnit; const offsetWidth = rendererWidth * (0.5 - pivotX); const offsetHeight = rendererHeight * (0.5 - pivotY); @@ -659,7 +654,7 @@ export function TextRenderable( private _buildChunk(textChunk: TextChunk, count: number): SubPrimitiveChunk { const { r, g, b, a } = this.color; - const finalAlpha = a * this._getTextAlpha(); + const finalAlpha = a * this._getAlpha(); const tempIndices = CharRenderInfo.triangles; const tempIndicesLength = tempIndices.length; const subChunk = (textChunk.subChunk = this._getChunkManager().allocateSubChunk(count * 4)); diff --git a/packages/core/src/2d/text/TextRenderer.ts b/packages/core/src/2d/text/TextRenderer.ts index f4f85232bb..d918b4c9d7 100644 --- a/packages/core/src/2d/text/TextRenderer.ts +++ b/packages/core/src/2d/text/TextRenderer.ts @@ -107,14 +107,30 @@ export class TextRenderer extends TextRenderable(Renderer) { return this.engine._batcherManager.primitiveChunkManager2D; } - override _getTextWidth(): number { + override _getWidth(): number { return this._width; } - override _getTextHeight(): number { + override _getHeight(): number { return this._height; } + override _getAlpha(): number { + return 1; + } + + override _getPivotX(): number { + return 0.5; + } + + override _getPivotY(): number { + return 0.5; + } + + override _getReferenceResolutionPerUnit(): number | undefined { + return undefined; + } + override _submitText(context: RenderContext, material: Material): void { const camera = context.camera; const engine = camera.engine; diff --git a/packages/ui/src/component/UIRenderer.ts b/packages/ui/src/component/UIRenderer.ts index 05275e2860..b84ad7333b 100644 --- a/packages/ui/src/component/UIRenderer.ts +++ b/packages/ui/src/component/UIRenderer.ts @@ -4,14 +4,20 @@ import { DependentMode, Entity, EntityModifyFlags, + Material, Matrix, Plane, Ray, + RenderContext, + RenderQueueFlags, Renderer, RendererUpdateFlags, ShaderMacroCollection, ShaderProperty, SpriteMaskInteraction, + SubPrimitiveChunk, + Texture2D, + Vector2, Vector3, Vector4, assignmentClone, @@ -20,6 +26,7 @@ import { ignoreClone } from "@galacean/engine"; import { Utils } from "../Utils"; +import { CanvasRenderMode } from "../enums/CanvasRenderMode"; import { UIHitResult } from "../input/UIHitResult"; import { IGraphics } from "../interface/IGraphics"; import { RectMask2D } from "./advanced/RectMask2D"; @@ -271,6 +278,105 @@ export class UIRenderer extends Renderer implements IGraphics { return this.engine._batcherManager.primitiveChunkManagerUI; } + // ===== Layout methods: default implementations for UI ===== + + /** + * Get width from UITransform. + * @internal + */ + _getWidth(): number { + return (this._transformEntity.transform).size.x; + } + + /** + * Get height from UITransform. + * @internal + */ + _getHeight(): number { + return (this._transformEntity.transform).size.y; + } + + /** + * Get pivot from UITransform. + * @internal + */ + _getPivot(): Vector2 { + return (this._transformEntity.transform).pivot; + } + + /** + * Get pivot X from UITransform. + * @internal + */ + _getPivotX(): number { + return (this._transformEntity.transform).pivot.x; + } + + /** + * Get pivot Y from UITransform. + * @internal + */ + _getPivotY(): number { + return (this._transformEntity.transform).pivot.y; + } + + /** + * Get alpha from UIGroup. + * @internal + */ + _getAlpha(): number { + return this._getGlobalAlpha(); + } + + /** + * Get reference resolution per unit from UICanvas. + * @internal + */ + _getReferenceResolutionPerUnit(): number | undefined { + return this._getRootCanvas()?.referenceResolutionPerUnit; + } + + /** + * Submit render element to canvas for UI rendering. + * @param context - The render context + * @param material - The material to use + * @param subChunk - The sub primitive chunk + * @param texture - The texture to use + * @param stencilOp - Stencil operation: 0 = test (read), 1 = increment (write). Default is 0. + * @param forceAllRenderQueue - Whether to force render in all render queues. Default is false. + * @internal + */ + _submitToCanvas( + context: RenderContext, + material: Material, + subChunk: SubPrimitiveChunk, + texture: Texture2D, + stencilOp: number = 0, + forceAllRenderQueue: boolean = false + ): void { + const canvas = this._getRootCanvas(); + if (!canvas) return; + + const engine = context.camera.engine; + const subRenderElement = engine._subRenderElementPool.get(); + subRenderElement.set(this, material, subChunk.chunk.primitive, subChunk.subMesh, texture, subChunk); + + // Set shader passes and render queue flags for overlay mode or forced all queues + if (forceAllRenderQueue || canvas._realRenderMode === CanvasRenderMode.ScreenSpaceOverlay) { + subRenderElement.shaderPasses = material.shader.subShaders[0].passes; + subRenderElement.renderQueueFlags = RenderQueueFlags.All; + } + + // Set stencil for hierarchy-based masking + const stencilDepth = this._uiStencilDepth; + if (stencilDepth > 0 || stencilOp !== 0) { + subRenderElement.uiStencilDepth = stencilDepth; + subRenderElement.uiStencilOp = stencilOp; + } + + canvas._renderElement.addSubRenderElement(subRenderElement); + } + /** * @internal */ diff --git a/packages/ui/src/component/advanced/Image.ts b/packages/ui/src/component/advanced/Image.ts index 3bded132d2..1ebc223e05 100644 --- a/packages/ui/src/component/advanced/Image.ts +++ b/packages/ui/src/component/advanced/Image.ts @@ -3,7 +3,6 @@ import { Entity, Material, RenderContext, - RenderQueueFlags, RendererUpdateFlags, SpriteDrawMode, SpriteRenderable, @@ -12,8 +11,6 @@ import { Texture2D, ignoreClone } from "@galacean/engine"; -import type { Vector2 } from "@galacean/engine"; -import { CanvasRenderMode } from "../../enums/CanvasRenderMode"; import { RootCanvasModifyFlags } from "../UICanvas"; import { UIRenderer } from "../UIRenderer"; import { UITransform, UITransformModifyFlags } from "../UITransform"; @@ -33,7 +30,7 @@ export class Image extends SpriteRenderable(UIRenderer) { // ===== Abstract implementations ===== /** @internal */ - override _getSpriteColor() { + override _getColor() { return this._color; } @@ -50,51 +47,7 @@ export class Image extends SpriteRenderable(UIRenderer) { subChunk: SubPrimitiveChunk, texture: Texture2D ): void { - const canvas = this._getRootCanvas(); - if (!canvas) return; - - const engine = context.camera.engine; - const subRenderElement = engine._subRenderElementPool.get(); - subRenderElement.set(this, material, subChunk.chunk.primitive, subChunk.subMesh, texture, subChunk); - - if (canvas._realRenderMode === CanvasRenderMode.ScreenSpaceOverlay) { - subRenderElement.shaderPasses = material.shader.subShaders[0].passes; - subRenderElement.renderQueueFlags = RenderQueueFlags.All; - } - - // Set UI stencil depth for hierarchy-based masking - const stencilDepth = this._uiStencilDepth; - if (stencilDepth > 0) { - subRenderElement.uiStencilDepth = stencilDepth; - subRenderElement.uiStencilOp = 0; // test (read stencil) - } - - canvas._renderElement.addSubRenderElement(subRenderElement); - } - - /** @internal */ - override _getSpriteWidth(): number { - return (this._transformEntity.transform).size.x; - } - - /** @internal */ - override _getSpriteHeight(): number { - return (this._transformEntity.transform).size.y; - } - - /** @internal */ - override _getSpritePivot(): Vector2 { - return (this._transformEntity.transform).pivot; - } - - // ===== Override defaults ===== - - override _getSpriteAlpha(): number { - return this._getGlobalAlpha(); - } - - override _getReferenceResolutionPerUnit(): number | undefined { - return this._getRootCanvas()?.referenceResolutionPerUnit; + this._submitToCanvas(context, material, subChunk, texture); } // ===== Image-specific ===== @@ -140,4 +93,4 @@ export class Image extends SpriteRenderable(UIRenderer) { } this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; } -} +} \ No newline at end of file diff --git a/packages/ui/src/component/advanced/Mask.ts b/packages/ui/src/component/advanced/Mask.ts index 351507b917..8f797e8e78 100644 --- a/packages/ui/src/component/advanced/Mask.ts +++ b/packages/ui/src/component/advanced/Mask.ts @@ -4,7 +4,7 @@ import { Material, PrimitiveChunkManager, RenderContext, - RenderQueueFlags, + RendererUpdateFlags, ShaderProperty, SubPrimitiveChunk, Texture2D, @@ -12,10 +12,7 @@ import { assignmentClone, ignoreClone } from "@galacean/engine"; -import type { Vector2 } from "@galacean/engine"; -import { CanvasRenderMode } from "../../enums/CanvasRenderMode"; import { UIRenderer } from "../UIRenderer"; -import { UITransform } from "../UITransform"; /** * UI component that masks descendant UI elements using a sprite shape. @@ -79,35 +76,8 @@ export class Mask extends SpriteRenderable(UIRenderer) { subChunk: SubPrimitiveChunk, texture: Texture2D ): void { - const canvas = this._getRootCanvas(); - if (!canvas) return; - - const engine = context.camera.engine; - const subRenderElement = engine._subRenderElementPool.get(); - subRenderElement.set(this, material, subChunk.chunk.primitive, subChunk.subMesh, texture, subChunk); - subRenderElement.shaderPasses = material.shader.subShaders[0].passes; - subRenderElement.renderQueueFlags = RenderQueueFlags.All; - - // Mark as stencil write (increment) at current stencil depth - subRenderElement.uiStencilDepth = this._uiStencilDepth; - subRenderElement.uiStencilOp = 1; // increment - - canvas._renderElement.addSubRenderElement(subRenderElement); - } - - /** @internal */ - override _getSpriteWidth(): number { - return (this._transformEntity.transform).size.x; - } - - /** @internal */ - override _getSpriteHeight(): number { - return (this._transformEntity.transform).size.y; - } - - /** @internal */ - override _getSpritePivot(): Vector2 { - return (this._transformEntity.transform).pivot; + // stencilOp = 1 (increment), forceAllRenderQueue = true + this._submitToCanvas(context, material, subChunk, texture, 1, true); } protected override _updateBounds(worldBounds: BoundingBox): void { @@ -123,7 +93,6 @@ export class Mask extends SpriteRenderable(UIRenderer) { @ignoreClone protected override _onTransformChanged(type: number): void { - // @ts-ignore - this._dirtyUpdateFlag |= 0x1; // RendererUpdateFlags.WorldVolume + this._dirtyUpdateFlag |= RendererUpdateFlags.WorldVolume; } -} +} \ No newline at end of file diff --git a/packages/ui/src/component/advanced/Text.ts b/packages/ui/src/component/advanced/Text.ts index befc05c2d9..c3436a4b41 100644 --- a/packages/ui/src/component/advanced/Text.ts +++ b/packages/ui/src/component/advanced/Text.ts @@ -27,26 +27,6 @@ export class Text extends TextRenderable(UIRenderer) { // ===== Abstract implementations ===== - override _getTextWidth(): number { - return (this._transformEntity.transform).size.x; - } - - override _getTextHeight(): number { - return (this._transformEntity.transform).size.y; - } - - override _getTextPivotX(): number { - return (this._transformEntity.transform).pivot.x; - } - - override _getTextPivotY(): number { - return (this._transformEntity.transform).pivot.y; - } - - override _getTextReferenceResolutionPerUnit(): number | undefined { - return this._getRootCanvas()?.referenceResolutionPerUnit; - } - override _submitText(context: RenderContext, material: Material): void { const canvas = this._getRootCanvas(); if (!canvas) return; @@ -80,10 +60,6 @@ export class Text extends TextRenderable(UIRenderer) { // ===== Override defaults ===== - override _getTextAlpha(): number { - return this._getGlobalAlpha(); - } - override _isTextHostInvisible(): boolean { return !this._getRootCanvas(); } @@ -124,4 +100,4 @@ export class Text extends TextRenderable(UIRenderer) { } super._onTransformChanged(type); } -} +} \ No newline at end of file