Files
esengine/packages/rendering/fairygui/src/render/FGUIRenderDataProvider.ts

1184 lines
42 KiB
TypeScript
Raw Normal View History

feat(fairygui): FairyGUI 完整集成 (#314) * feat(fairygui): FairyGUI ECS 集成核心架构 实现 FairyGUI 的 ECS 原生集成,完全替代旧 UI 系统: 核心类: - GObject: UI 对象基类,支持变换、可见性、关联、齿轮 - GComponent: 容器组件,管理子对象和控制器 - GRoot: 根容器,管理焦点、弹窗、输入分发 - GGroup: 组容器,支持水平/垂直布局 抽象层: - DisplayObject: 显示对象基类 - EventDispatcher: 事件分发 - Timer: 计时器 - Stage: 舞台,管理输入和缩放 布局系统: - Relations: 约束关联管理 - RelationItem: 24 种关联类型 基础设施: - Controller: 状态控制器 - Transition: 过渡动画 - ScrollPane: 滚动面板 - UIPackage: 包管理 - ByteBuffer: 二进制解析 * refactor(ui): 删除旧 UI 系统,使用 FairyGUI 替代 * feat(fairygui): 实现 UI 控件 - 添加显示类:Image、TextField、Graph - 添加基础控件:GImage、GTextField、GGraph - 添加交互控件:GButton、GProgressBar、GSlider - 更新 IRenderCollector 支持 Graph 渲染 - 扩展 Controller 添加 selectedPageId - 添加 STATE_CHANGED 事件类型 * feat(fairygui): 现代化架构重构 - 增强 EventDispatcher 支持类型安全、优先级和传播控制 - 添加 PropertyBinding 响应式属性绑定系统 - 添加 ServiceContainer 依赖注入容器 - 添加 UIConfig 全局配置系统 - 添加 UIObjectFactory 对象工厂 - 实现 RenderBridge 渲染桥接层 - 实现 Canvas2DBackend 作为默认渲染后端 - 扩展 IRenderCollector 支持更多图元类型 * feat(fairygui): 九宫格渲染和资源加载修复 - 修复 FGUIUpdateSystem 支持路径和 GUID 两种加载方式 - 修复 GTextInput 同时设置 _displayObject 和 _textField - 实现九宫格渲染展开为 9 个子图元 - 添加 sourceWidth/sourceHeight 用于九宫格计算 - 添加 DOMTextRenderer 文本渲染层(临时方案) * fix(fairygui): 修复 GGraph 颜色读取 * feat(fairygui): 虚拟节点 Inspector 和文本渲染支持 * fix(fairygui): 编辑器状态刷新和遗留引用修复 - 修复切换 FGUI 包后组件列表未刷新问题 - 修复切换组件后 viewport 未清理旧内容问题 - 修复虚拟节点在包加载后未刷新问题 - 重构为事件驱动架构,移除轮询机制 - 修复 @esengine/ui 遗留引用,统一使用 @esengine/fairygui * fix: 移除 tsconfig 中的 @esengine/ui 引用
2025-12-22 10:52:54 +08:00
/**
* FGUIRenderDataProvider
*
* Converts FairyGUI render primitives to engine render data format.
* Implements IUIRenderDataProvider for integration with the engine render system.
*
* FairyGUI
* IUIRenderDataProvider
*/
import type { IRenderPrimitive } from './IRenderCollector';
import { ERenderPrimitiveType } from './IRenderCollector';
import type { RenderCollector } from './RenderCollector';
import { Stage } from '../core/Stage';
import { getMSDFFontManager, MSDFFont } from '../text/MSDFFont';
import { getDynamicFontManager } from '../text/DynamicFont';
import { layoutText } from '../text/TextLayout';
import { createTextBatch } from '../text/TextBatch';
import { EAlignType, EVertAlignType, EGraphType } from '../core/FieldTypes';
import { GraphMeshGenerator, type GraphMeshData } from './GraphMeshGenerator';
/**
* Engine render data format (matches ProviderRenderData from ecs-engine-bindgen)
* ecs-engine-bindgen ProviderRenderData
*/
export interface IEngineRenderData {
transforms: Float32Array;
textureIds: Uint32Array;
uvs: Float32Array;
colors: Uint32Array;
tileCount: number;
sortingLayer: string;
orderInLayer: number;
textureGuid?: string;
bScreenSpace?: boolean;
clipRect?: { x: number; y: number; width: number; height: number };
}
/**
* MSDF text render data format
* MSDF
*/
export interface ITextRenderData {
/** Vertex positions [x, y, ...] for each vertex (4 per glyph) | 顶点位置 */
positions: Float32Array;
/** Texture coordinates [u, v, ...] for each vertex | 纹理坐标 */
texCoords: Float32Array;
/** Colors [r, g, b, a, ...] for each vertex | 颜色 */
colors: Float32Array;
/** Outline colors [r, g, b, a, ...] for each vertex | 描边颜色 */
outlineColors: Float32Array;
/** Outline widths for each vertex | 描边宽度 */
outlineWidths: Float32Array;
/** Font texture ID | 字体纹理 ID */
textureId: number;
/** Pixel range for MSDF shader | 着色器像素范围 */
pxRange: number;
/** Glyph count | 字形数量 */
glyphCount: number;
}
/**
* Mesh render data format for arbitrary 2D geometry
* 2D
*/
export interface IMeshRenderData {
/** Vertex positions [x, y, ...] | 顶点位置 */
positions: Float32Array;
/** Texture coordinates [u, v, ...] | 纹理坐标 */
uvs: Float32Array;
/** Vertex colors (packed RGBA) | 顶点颜色 */
colors: Uint32Array;
/** Triangle indices | 三角形索引 */
indices: Uint16Array;
/** Texture ID (0 = white pixel) | 纹理 ID */
textureId: number;
}
/**
* Render data provider interface (matches IRenderDataProvider from ecs-engine-bindgen)
*
*/
export interface IFGUIRenderDataProvider {
getRenderData(): readonly IEngineRenderData[];
getTextRenderData(): readonly ITextRenderData[];
getMeshRenderData(): readonly IMeshRenderData[];
}
/**
* Texture resolver callback
*
*/
export type TextureResolverFn = (textureId: string | number) => number;
/**
* FGUIRenderDataProvider
*
* Converts FairyGUI render collector data to engine render data format.
*
* FairyGUI
*/
export class FGUIRenderDataProvider implements IFGUIRenderDataProvider {
/** Sorting layer for UI | UI 排序层 */
private _sortingLayer: string = 'UI';
/** Order within the sorting layer | 层内排序顺序 */
private _orderInLayer: number = 0;
/** Render collector reference | 渲染收集器引用 */
private _collector: RenderCollector | null = null;
/** Texture resolver function | 纹理解析函数 */
private _textureResolver: TextureResolverFn | null = null;
/** Cached render data | 缓存的渲染数据 */
private _cachedData: IEngineRenderData[] = [];
/** Cached text render data | 缓存的文本渲染数据 */
private _cachedTextData: ITextRenderData[] = [];
/** Cached mesh render data | 缓存的网格渲染数据 */
private _cachedMeshData: IMeshRenderData[] = [];
/** Default texture ID when texture not found | 找不到纹理时的默认纹理 ID */
private _defaultTextureId: number = 0;
/** Canvas width for coordinate conversion | 画布宽度,用于坐标转换 */
private _canvasWidth: number = 0;
/** Canvas height for coordinate conversion | 画布高度,用于坐标转换 */
private _canvasHeight: number = 0;
/**
* Set the render collector
*
*/
public setCollector(collector: RenderCollector): void {
this._collector = collector;
}
/**
* Set sorting layer and order
*
*/
public setSorting(layer: string, order: number): void {
this._sortingLayer = layer;
this._orderInLayer = order;
}
/**
* Set texture resolver function
*
*/
public setTextureResolver(resolver: TextureResolverFn): void {
this._textureResolver = resolver;
}
/**
* Set default texture ID
* ID
*/
public setDefaultTextureId(id: number): void {
this._defaultTextureId = id;
}
/**
* Set canvas size for coordinate conversion
*
*
* FGUI uses top-left origin with Y-down coordinate system.
* Engine uses center origin with visible area [-width/2, width/2] and [-height/2, height/2].
*
* FGUI 使Y
* 使 [-width/2, width/2] [-height/2, height/2]
*/
public setCanvasSize(width: number, height: number): void {
this._canvasWidth = width;
this._canvasHeight = height;
}
/**
* Get effective canvas size for coordinate conversion
* Uses Stage.designWidth/designHeight as fallback if not explicitly set
*
*
* 使 Stage.designWidth/designHeight 退
*/
private getEffectiveCanvasSize(): { width: number; height: number } {
if (this._canvasWidth > 0 && this._canvasHeight > 0) {
return { width: this._canvasWidth, height: this._canvasHeight };
}
// Fallback to Stage design size
// 回退到 Stage 设计尺寸
const stage = Stage.inst;
return {
width: stage.designWidth,
height: stage.designHeight
};
}
/**
* Get render data for the engine
*
*/
public getRenderData(): readonly IEngineRenderData[] {
if (!this._collector) {
return [];
}
const primitives = this._collector.getPrimitives();
if (primitives.length === 0) {
return [];
}
this._cachedData.length = 0;
// Group primitives by texture for batching
const batches = this.groupByTexture(primitives);
for (const [_textureKey, batch] of batches) {
const renderData = this.convertBatch(batch);
if (renderData) {
this._cachedData.push(renderData);
}
}
return this._cachedData;
}
/**
* Get text render data for the engine
*
*
* Note: MSDF text rendering requires font atlas to be loaded.
* Text primitives are converted to MSDF glyph batches.
*
* MSDF
* MSDF
*/
public getTextRenderData(): readonly ITextRenderData[] {
if (!this._collector) {
return [];
}
const primitives = this._collector.getPrimitives();
this._cachedTextData.length = 0;
// Get canvas size for coordinate conversion
const canvasSize = this.getEffectiveCanvasSize();
const halfWidth = canvasSize.width / 2;
const halfHeight = canvasSize.height / 2;
// Get font managers
const msdfFontManager = getMSDFFontManager();
const dynamicFontManager = getDynamicFontManager();
// Group text primitives by font for batching
const textBatches = new Map<string, IRenderPrimitive[]>();
for (const primitive of primitives) {
if (primitive.type !== ERenderPrimitiveType.Text) continue;
if (!primitive.text) continue;
const fontName = primitive.font || '';
let batch = textBatches.get(fontName);
if (!batch) {
batch = [];
textBatches.set(fontName, batch);
}
batch.push(primitive);
}
// Convert each font batch to render data
for (const [fontName, batch] of textBatches) {
// Try MSDF font first
let font: MSDFFont | undefined = msdfFontManager.getFont(fontName);
// Try dynamic font if MSDF not available
if (!font) {
// Try exact name first, then fallback to 'default'
let dynamicFont = dynamicFontManager.getFont(fontName);
if (!dynamicFont && fontName !== 'default') {
dynamicFont = dynamicFontManager.getFont('default');
}
if (dynamicFont) {
// Request characters for all text in this batch
for (const primitive of batch) {
if (primitive.text) {
dynamicFont.requestCharacters(primitive.text);
}
}
// Register as MSDF font if not already
font = msdfFontManager.getFont(dynamicFont.name);
if (!font) {
font = dynamicFont.registerAsMSDFFont();
}
}
}
if (!font) {
// Skip if no font available - will fall back to DOM renderer
continue;
}
for (const primitive of batch) {
const textRenderData = this.convertTextPrimitive(
primitive,
font,
halfWidth,
halfHeight
);
if (textRenderData) {
this._cachedTextData.push(textRenderData);
}
}
}
return this._cachedTextData;
}
/**
* Get mesh render data for the engine
*
*
* Used for rendering complex shapes like ellipses, polygons, etc.
*
*/
public getMeshRenderData(): readonly IMeshRenderData[] {
if (!this._collector) {
return [];
}
const primitives = this._collector.getPrimitives();
this._cachedMeshData.length = 0;
// Get canvas size for coordinate conversion
const canvasSize = this.getEffectiveCanvasSize();
const halfWidth = canvasSize.width / 2;
const halfHeight = canvasSize.height / 2;
for (const primitive of primitives) {
if (primitive.type !== ERenderPrimitiveType.Graph) continue;
const graphType = primitive.graphType;
// Skip simple rectangles - they're handled by getRenderData using white pixel
if (graphType === EGraphType.Rect) continue;
// Generate mesh for complex shapes
const meshData = this.generateGraphMesh(primitive, halfWidth, halfHeight);
if (meshData) {
this._cachedMeshData.push(meshData);
}
// Generate outline mesh if needed
if (primitive.lineSize && primitive.lineSize > 0) {
const outlineMesh = this.generateGraphOutlineMesh(primitive, halfWidth, halfHeight);
if (outlineMesh) {
this._cachedMeshData.push(outlineMesh);
}
}
}
return this._cachedMeshData;
}
/**
* Generate mesh data for a graph primitive
*
*/
private generateGraphMesh(
primitive: IRenderPrimitive,
halfWidth: number,
halfHeight: number
): IMeshRenderData | null {
const graphType = primitive.graphType;
const width = primitive.width;
const height = primitive.height;
const fillColor = primitive.fillColor ?? 0xFFFFFFFF;
let meshData: GraphMeshData | null = null;
switch (graphType) {
case EGraphType.Ellipse:
meshData = GraphMeshGenerator.generateEllipse(width, height, fillColor);
break;
case EGraphType.Polygon:
if (primitive.polygonPoints) {
meshData = GraphMeshGenerator.generatePolygon(
primitive.polygonPoints,
width,
height,
fillColor
);
}
break;
case EGraphType.RegularPolygon:
// Generate regular polygon points
const sides = primitive.sides ?? 6;
const points = this.generateRegularPolygonPoints(width, height, sides);
meshData = GraphMeshGenerator.generatePolygon(points, width, height, fillColor);
break;
}
if (!meshData || meshData.positions.length === 0) {
return null;
}
// Get world position from matrix
const m = primitive.worldMatrix;
const baseX = m ? m[4] : 0;
const baseY = m ? m[5] : 0;
// Convert FGUI coordinates to engine coordinates
const engineBaseX = baseX - halfWidth;
const engineBaseY = halfHeight - baseY;
// Apply coordinate transformation to positions
const positions = new Float32Array(meshData.positions.length);
for (let i = 0; i < meshData.positions.length; i += 2) {
positions[i] = meshData.positions[i] + engineBaseX;
// Flip Y for engine coordinate system
positions[i + 1] = engineBaseY - meshData.positions[i + 1];
}
return {
positions,
uvs: new Float32Array(meshData.uvs),
colors: new Uint32Array(meshData.colors),
indices: new Uint16Array(meshData.indices),
textureId: 0 // White pixel texture
};
}
/**
* Generate outline mesh data for a graph primitive
*
*/
private generateGraphOutlineMesh(
primitive: IRenderPrimitive,
halfWidth: number,
halfHeight: number
): IMeshRenderData | null {
const graphType = primitive.graphType;
const width = primitive.width;
const height = primitive.height;
const lineWidth = primitive.lineSize ?? 1;
const lineColor = primitive.lineColor ?? 0x000000FF;
let meshData: GraphMeshData | null = null;
switch (graphType) {
case EGraphType.Ellipse:
meshData = GraphMeshGenerator.generateEllipseOutline(width, height, lineWidth, lineColor);
break;
case EGraphType.Polygon:
if (primitive.polygonPoints) {
meshData = GraphMeshGenerator.generateOutline(
primitive.polygonPoints,
lineWidth,
lineColor,
true
);
}
break;
case EGraphType.RegularPolygon:
const sides = primitive.sides ?? 6;
const points = this.generateRegularPolygonPoints(width, height, sides);
meshData = GraphMeshGenerator.generateOutline(points, lineWidth, lineColor, true);
break;
}
if (!meshData || meshData.positions.length === 0) {
return null;
}
// Get world position from matrix
const m = primitive.worldMatrix;
const baseX = m ? m[4] : 0;
const baseY = m ? m[5] : 0;
// Convert FGUI coordinates to engine coordinates
const engineBaseX = baseX - halfWidth;
const engineBaseY = halfHeight - baseY;
// Apply coordinate transformation to positions
const positions = new Float32Array(meshData.positions.length);
for (let i = 0; i < meshData.positions.length; i += 2) {
positions[i] = meshData.positions[i] + engineBaseX;
positions[i + 1] = engineBaseY - meshData.positions[i + 1];
}
return {
positions,
uvs: new Float32Array(meshData.uvs),
colors: new Uint32Array(meshData.colors),
indices: new Uint16Array(meshData.indices),
textureId: 0 // White pixel texture
};
}
/**
* Generate points for a regular polygon
*
*/
private generateRegularPolygonPoints(width: number, height: number, sides: number): number[] {
const points: number[] = [];
const centerX = width / 2;
const centerY = height / 2;
const radiusX = width / 2;
const radiusY = height / 2;
const angleStep = (Math.PI * 2) / sides;
const startAngle = -Math.PI / 2; // Start from top
for (let i = 0; i < sides; i++) {
const angle = startAngle + angleStep * i;
points.push(
centerX + Math.cos(angle) * radiusX,
centerY + Math.sin(angle) * radiusY
);
}
return points;
}
/**
* Convert a text primitive to MSDF render data
* MSDF
*/
private convertTextPrimitive(
primitive: IRenderPrimitive,
font: MSDFFont,
halfWidth: number,
halfHeight: number
): ITextRenderData | null {
const text = primitive.text;
if (!text) return null;
// Get text properties
const fontSize = primitive.fontSize ?? 12;
const maxWidth = primitive.width;
const maxHeight = primitive.height;
// Convert alignment types
let align = EAlignType.Left;
if (primitive.align !== undefined) {
align = typeof primitive.align === 'string'
? this.parseHAlign(primitive.align)
: primitive.align;
} else if (primitive.textAlign !== undefined) {
align = this.parseHAlign(primitive.textAlign as string);
}
let valign = EVertAlignType.Top;
if (primitive.valign !== undefined) {
valign = typeof primitive.valign === 'string'
? this.parseVAlign(primitive.valign)
: primitive.valign;
} else if (primitive.textVAlign !== undefined) {
valign = this.parseVAlign(primitive.textVAlign as string);
}
// Layout text
const layoutResult = layoutText({
font,
text,
fontSize,
maxWidth,
maxHeight,
align,
valign,
lineHeight: 1.2,
letterSpacing: primitive.letterSpacing ?? 0,
wordWrap: primitive.wordWrap ?? false,
singleLine: primitive.singleLine ?? false
});
if (layoutResult.glyphs.length === 0) {
return null;
}
// Get world position from matrix
const m = primitive.worldMatrix;
const baseX = m ? m[4] : (primitive.x ?? 0);
const baseY = m ? m[5] : (primitive.y ?? 0);
// Convert FGUI coordinates to engine coordinates
// FGUI: top-left origin, Y-down
// Engine: center origin, Y-up
const engineBaseX = baseX - halfWidth;
const engineBaseY = halfHeight - baseY;
// Get text color
const textColor = primitive.textColor ?? primitive.color ?? 0xFFFFFFFF;
// Create text batch with coordinate offset
const batchData = createTextBatch(
layoutResult.glyphs,
font.textureId,
font.pxRange,
{
color: textColor,
alpha: primitive.alpha,
outlineColor: primitive.outlineColor,
outlineWidth: primitive.outlineWidth ?? primitive.stroke,
offsetX: engineBaseX,
offsetY: engineBaseY
}
);
// Convert Y coordinates (flip for engine coordinate system)
// The batch was created in FGUI Y-down space, need to flip
this.flipTextYCoordinates(batchData.positions, engineBaseY);
return {
positions: batchData.positions,
texCoords: batchData.texCoords,
colors: batchData.colors,
outlineColors: batchData.outlineColors,
outlineWidths: batchData.outlineWidths,
textureId: batchData.textureId,
pxRange: batchData.pxRange,
glyphCount: batchData.glyphCount
};
}
/**
* Flip Y coordinates for engine coordinate system
* Y
*/
private flipTextYCoordinates(positions: Float32Array, baseY: number): void {
// Each glyph has 4 vertices, each vertex has 2 floats (x, y)
// Flip Y: newY = baseY - (y - baseY) = 2 * baseY - y
// But since we already offset, we just negate the Y relative to base
for (let i = 1; i < positions.length; i += 2) {
// Y is at odd indices
// Convert from Y-down to Y-up by negating the local Y offset
positions[i] = baseY - (positions[i] - baseY);
}
}
/**
* Parse horizontal alignment string
*
*/
private parseHAlign(align: string): EAlignType {
switch (align.toLowerCase()) {
case 'center': return EAlignType.Center;
case 'right': return EAlignType.Right;
default: return EAlignType.Left;
}
}
/**
* Parse vertical alignment string
*
*/
private parseVAlign(align: string): EVertAlignType {
switch (align.toLowerCase()) {
case 'middle': return EVertAlignType.Middle;
case 'bottom': return EVertAlignType.Bottom;
default: return EVertAlignType.Top;
}
}
/**
* Group primitives by texture for batching
*
*/
private groupByTexture(primitives: readonly IRenderPrimitive[]): Map<string | number | undefined, IRenderPrimitive[]> {
const batches = new Map<string | number | undefined, IRenderPrimitive[]>();
for (const primitive of primitives) {
// Handle image primitives
// 处理图像图元
if (primitive.type === ERenderPrimitiveType.Image) {
const key = primitive.textureId ?? 'none';
let batch = batches.get(key);
if (!batch) {
batch = [];
batches.set(key, batch);
}
// Expand nine-slice primitives into 9 sub-primitives
// 将九宫格图元展开为 9 个子图元
if (primitive.scale9Grid) {
const subPrimitives = this.expandScale9Grid(primitive);
batch.push(...subPrimitives);
} else {
batch.push(primitive);
}
}
// Handle graph primitives (rect, ellipse, polygon)
// 处理图形图元(矩形、椭圆、多边形)
else if (primitive.type === ERenderPrimitiveType.Graph) {
// Use special key for graph primitives (white pixel texture)
// 为图形图元使用特殊键(白色像素纹理)
const key = '__fgui_white_pixel__';
let batch = batches.get(key);
if (!batch) {
batch = [];
batches.set(key, batch);
}
// Convert graph fill to primitive for rendering
// 将图形填充转换为图元进行渲染
const fillPrimitive = this.convertGraphToPrimitive(primitive);
if (fillPrimitive) {
batch.push(fillPrimitive);
}
// Generate outline (stroke) if lineSize > 0
// 如果 lineSize > 0生成轮廓线描边
if (primitive.lineSize && primitive.lineSize > 0) {
const outlinePrimitives = this.convertGraphOutlineToPrimitive(primitive);
batch.push(...outlinePrimitives);
}
}
}
return batches;
}
/**
* Convert a graph primitive to image-like primitives for rendering
*
*
* Currently supports only filled rectangles using a white pixel texture.
* Ellipse, polygon, and rounded rectangles require mesh rendering support
* which needs to be added to the engine.
*
* 使
*
*/
private convertGraphToPrimitive(primitive: IRenderPrimitive): IRenderPrimitive | null {
const graphType = primitive.graphType;
// Only support simple filled rectangles for now
// Other shapes (ellipse, polygon, rounded rect) need mesh rendering support in the engine
// 目前只支持简单填充矩形
// 其他形状(椭圆、多边形、圆角矩形)需要引擎中的网格渲染支持
if (graphType !== EGraphType.Rect) {
// TODO: Add mesh batch rendering to engine for complex shapes
// TODO: 在引擎中添加网格批处理渲染以支持复杂形状
return null;
}
// Simple filled rectangle - use white pixel texture with fillColor
// 简单填充矩形 - 使用白色像素纹理配合 fillColor
return {
type: ERenderPrimitiveType.Image,
sortOrder: primitive.sortOrder,
worldMatrix: primitive.worldMatrix,
width: primitive.width,
height: primitive.height,
alpha: primitive.alpha,
grayed: primitive.grayed,
blendMode: primitive.blendMode,
clipRect: primitive.clipRect,
textureId: '__fgui_white_pixel__',
uvRect: [0, 0, 1, 1],
color: primitive.fillColor ?? 0xFFFFFFFF
};
}
/**
* Convert graph outline to primitive for rendering
* 线
*
* Currently only supports rectangle outlines (rendered as 4 thin rectangles).
* Other shapes need mesh rendering support in the engine.
*
* 4
*
*/
private convertGraphOutlineToPrimitive(primitive: IRenderPrimitive): IRenderPrimitive[] {
const graphType = primitive.graphType;
// Only support rectangle outlines for now
// 目前只支持矩形轮廓
if (graphType !== EGraphType.Rect) {
return [];
}
const lineWidth = primitive.lineSize ?? 1;
const lineColor = primitive.lineColor ?? 0x000000FF;
const width = primitive.width;
const height = primitive.height;
// Get position from world matrix
const m = primitive.worldMatrix;
const baseX = m ? m[4] : 0;
const baseY = m ? m[5] : 0;
const result: IRenderPrimitive[] = [];
// Create 4 thin rectangles for the outline
// Top edge
result.push(this.createOutlineRect(baseX, baseY, width, lineWidth, lineColor, primitive));
// Bottom edge
result.push(this.createOutlineRect(baseX, baseY + height - lineWidth, width, lineWidth, lineColor, primitive));
// Left edge (excluding corners already covered by top/bottom)
result.push(this.createOutlineRect(baseX, baseY + lineWidth, lineWidth, height - lineWidth * 2, lineColor, primitive));
// Right edge
result.push(this.createOutlineRect(baseX + width - lineWidth, baseY + lineWidth, lineWidth, height - lineWidth * 2, lineColor, primitive));
return result;
}
/**
* Create a single outline rectangle primitive
*
*/
private createOutlineRect(
x: number,
y: number,
width: number,
height: number,
color: number,
source: IRenderPrimitive
): IRenderPrimitive {
// Create new world matrix with the position
const matrix = new Float32Array(6);
matrix[0] = 1;
matrix[1] = 0;
matrix[2] = 0;
matrix[3] = 1;
matrix[4] = x;
matrix[5] = y;
return {
type: ERenderPrimitiveType.Image,
sortOrder: source.sortOrder + 0.1,
worldMatrix: matrix,
width,
height,
alpha: source.alpha,
grayed: source.grayed,
blendMode: source.blendMode,
clipRect: source.clipRect,
textureId: '__fgui_white_pixel__',
uvRect: [0, 0, 1, 1],
color
};
}
/**
* Expand a nine-slice primitive into 9 sub-primitives
* 9
*
* Nine-slice grid divides the image into 9 regions:
* +-------+---------------+-------+
* | 0 | 1 | 2 | (top row)
* | (TL) | (TC) | (TR) |
* +-------+---------------+-------+
* | 3 | 4 | 5 | (middle row)
* | (ML) | (center) | (MR) |
* +-------+---------------+-------+
* | 6 | 7 | 8 | (bottom row)
* | (BL) | (BC) | (BR) |
* +-------+---------------+-------+
*
* Corners (0,2,6,8): Keep original size
* Edges (1,3,5,7): Stretch in one direction
* Center (4): Stretch in both directions
*/
private expandScale9Grid(primitive: IRenderPrimitive): IRenderPrimitive[] {
const grid = primitive.scale9Grid!;
const result: IRenderPrimitive[] = [];
// Source dimensions (original texture region)
// 源尺寸(原始纹理区域)
const srcWidth = primitive.sourceWidth ?? primitive.width;
const srcHeight = primitive.sourceHeight ?? primitive.height;
// Grid boundaries in source space
// 源空间中的九宫格边界
const left = grid.x;
const top = grid.y;
const right = grid.x + grid.width;
const bottom = grid.y + grid.height;
// Target dimensions (stretched)
// 目标尺寸(拉伸后)
const targetWidth = primitive.width;
const targetHeight = primitive.height;
// Calculate stretched middle section sizes
const cornerLeftWidth = left;
const cornerRightWidth = srcWidth - right;
const cornerTopHeight = top;
const cornerBottomHeight = srcHeight - bottom;
// Middle section in target space
const middleWidth = Math.max(0, targetWidth - cornerLeftWidth - cornerRightWidth);
const middleHeight = Math.max(0, targetHeight - cornerTopHeight - cornerBottomHeight);
// UV coordinates from primitive
const uvRect = primitive.uvRect || [0, 0, 1, 1];
const u0 = uvRect[0];
const v0 = uvRect[1];
const u1 = uvRect[2];
const v1 = uvRect[3];
// Calculate UV deltas per pixel
const uPerPixel = (u1 - u0) / srcWidth;
const vPerPixel = (v1 - v0) / srcHeight;
// UV boundaries for nine-slice
const uLeft = u0 + left * uPerPixel;
const uRight = u0 + right * uPerPixel;
const vTop = v0 + top * vPerPixel;
const vBottom = v0 + bottom * vPerPixel;
// World matrix for positioning
const m = primitive.worldMatrix;
const baseX = m ? m[4] : (primitive.x ?? 0);
const baseY = m ? m[5] : (primitive.y ?? 0);
// Extract scale from matrix
const matrixScaleX = m ? Math.sqrt(m[0] * m[0] + m[1] * m[1]) : 1;
const matrixScaleY = m ? Math.sqrt(m[2] * m[2] + m[3] * m[3]) : 1;
// Helper to create a sub-primitive
const createSubPrimitive = (
offsetX: number,
offsetY: number,
width: number,
height: number,
uvX0: number,
uvY0: number,
uvX1: number,
uvY1: number
): IRenderPrimitive | null => {
if (width <= 0 || height <= 0) return null;
// Create new world matrix with offset position
const subMatrix = new Float32Array(6);
if (m) {
subMatrix[0] = m[0];
subMatrix[1] = m[1];
subMatrix[2] = m[2];
subMatrix[3] = m[3];
// Apply offset in local space, then transform
subMatrix[4] = baseX + offsetX * matrixScaleX;
subMatrix[5] = baseY + offsetY * matrixScaleY;
} else {
subMatrix[0] = 1;
subMatrix[1] = 0;
subMatrix[2] = 0;
subMatrix[3] = 1;
subMatrix[4] = baseX + offsetX;
subMatrix[5] = baseY + offsetY;
}
return {
type: primitive.type,
sortOrder: primitive.sortOrder,
worldMatrix: subMatrix,
width: width,
height: height,
alpha: primitive.alpha,
grayed: primitive.grayed,
blendMode: primitive.blendMode,
clipRect: primitive.clipRect,
textureId: primitive.textureId,
uvRect: [uvX0, uvY0, uvX1, uvY1],
color: primitive.color
};
};
// Row positions (Y offsets)
const rowY = [0, cornerTopHeight, cornerTopHeight + middleHeight];
// Row heights
const rowH = [cornerTopHeight, middleHeight, cornerBottomHeight];
// Row UV Y coordinates
const rowUV = [
[v0, vTop],
[vTop, vBottom],
[vBottom, v1]
];
// Column positions (X offsets)
const colX = [0, cornerLeftWidth, cornerLeftWidth + middleWidth];
// Column widths
const colW = [cornerLeftWidth, middleWidth, cornerRightWidth];
// Column UV X coordinates
const colUV = [
[u0, uLeft],
[uLeft, uRight],
[uRight, u1]
];
// Generate 9 sub-primitives
for (let row = 0; row < 3; row++) {
for (let col = 0; col < 3; col++) {
const sub = createSubPrimitive(
colX[col],
rowY[row],
colW[col],
rowH[row],
colUV[col][0],
rowUV[row][0],
colUV[col][1],
rowUV[row][1]
);
if (sub) {
result.push(sub);
}
}
}
return result;
}
/**
* Convert a batch of primitives to engine render data
*
*/
private convertBatch(batch: IRenderPrimitive[]): IEngineRenderData | null {
if (batch.length === 0) return null;
const tileCount = batch.length;
// Allocate arrays
// Transform: [x, y, rotation, scaleX, scaleY, originX, originY] per tile (7 floats)
const transforms = new Float32Array(tileCount * 7);
const textureIds = new Uint32Array(tileCount);
const uvs = new Float32Array(tileCount * 4); // [u, v, uWidth, vHeight] per tile
const colors = new Uint32Array(tileCount);
let clipRect: { x: number; y: number; width: number; height: number } | undefined;
// Get effective canvas size for coordinate conversion
// 获取坐标转换的有效画布尺寸
const canvasSize = this.getEffectiveCanvasSize();
const halfWidth = canvasSize.width / 2;
const halfHeight = canvasSize.height / 2;
for (let i = 0; i < tileCount; i++) {
const primitive = batch[i];
const baseTransformIdx = i * 7;
const baseUvIdx = i * 4;
// Extract transform from world matrix
// Convert from FGUI coordinate system (top-left origin, Y-down)
// to engine coordinate system (center origin, Y-up)
//
// FGUI: (0, 0) = top-left, Y increases downward
// Engine: (0, 0) = center, Y increases upward
//
// Conversion formula:
// engineX = fguiX - canvasWidth/2
// engineY = canvasHeight/2 - fguiY (flip Y and offset)
//
// 从 FGUI 坐标系左上角原点Y 向下转换到引擎坐标系中心原点Y 向上)
const m = primitive.worldMatrix;
if (m) {
// Convert position from FGUI to engine coordinates
// 将位置从 FGUI 坐标转换为引擎坐标
transforms[baseTransformIdx + 0] = m[4] - halfWidth;
transforms[baseTransformIdx + 1] = halfHeight - m[5]; // Flip Y: halfHeight - fguiY
// Extract rotation from matrix (negate for Y-flip)
// 从矩阵提取旋转(因 Y 翻转而取反)
transforms[baseTransformIdx + 2] = -Math.atan2(m[1], m[0]);
// Extract scale from matrix and multiply by sprite dimensions
const matrixScaleX = Math.sqrt(m[0] * m[0] + m[1] * m[1]);
const matrixScaleY = Math.sqrt(m[2] * m[2] + m[3] * m[3]);
transforms[baseTransformIdx + 3] = matrixScaleX * primitive.width;
transforms[baseTransformIdx + 4] = matrixScaleY * primitive.height;
// Origin: In FGUI, position refers to top-left corner of the sprite
// In engine Y-up system: origin (0, 1) = top-left corner
// The sprite extends from position downward (negative Y in engine)
// 原点:在 FGUI 中,位置指精灵的左上角
// 在引擎 Y 向上坐标系中origin (0, 1) = 左上角
// 精灵从位置点向下延伸(引擎中 Y 负方向)
transforms[baseTransformIdx + 5] = 0; // originX: left
transforms[baseTransformIdx + 6] = 1; // originY: top
} else {
// Use position and dimensions directly (fallback path)
// 直接使用位置和尺寸(回退路径)
const x = primitive.x ?? 0;
const y = primitive.y ?? 0;
transforms[baseTransformIdx + 0] = x - halfWidth;
transforms[baseTransformIdx + 1] = halfHeight - y; // Flip Y
transforms[baseTransformIdx + 2] = 0;
transforms[baseTransformIdx + 3] = primitive.width;
transforms[baseTransformIdx + 4] = primitive.height;
transforms[baseTransformIdx + 5] = 0; // originX: left
transforms[baseTransformIdx + 6] = 1; // originY: top
}
// Resolve texture ID
if (this._textureResolver && primitive.textureId !== undefined) {
textureIds[i] = this._textureResolver(primitive.textureId);
} else {
textureIds[i] = this._defaultTextureId;
}
// UVs - engine expects [u0, v0, u1, v1] format in image coordinate system
// Engine vertex layout maps tex_coords as:
// vertex 0 (top-left) -> [u0, v0]
// vertex 1 (top-right) -> [u1, v0]
// vertex 2 (bottom-right) -> [u1, v1]
// vertex 3 (bottom-left) -> [u0, v1]
// This means v0 = top, v1 = bottom (image coordinate system, NOT OpenGL)
// FGUI sprite.rect uses image coordinates (y=0 at top), so no flip needed
//
// UV 格式:引擎期望图片坐标系的 [u0, v0, u1, v1]
// 引擎顶点布局将纹理坐标映射为v0 = 顶部v1 = 底部(图片坐标系)
// FGUI 的 sprite.rect 使用图片坐标y=0 在顶部),所以不需要翻转
if (primitive.uvRect) {
uvs[baseUvIdx + 0] = primitive.uvRect[0]; // u0
uvs[baseUvIdx + 1] = primitive.uvRect[1]; // v0 (top)
uvs[baseUvIdx + 2] = primitive.uvRect[2]; // u1
uvs[baseUvIdx + 3] = primitive.uvRect[3]; // v1 (bottom)
} else {
// Default full UV
uvs[baseUvIdx + 0] = 0;
uvs[baseUvIdx + 1] = 0;
uvs[baseUvIdx + 2] = 1;
uvs[baseUvIdx + 3] = 1;
}
// Color (0xRRGGBBAA format)
if (primitive.color !== undefined) {
colors[i] = primitive.color;
} else {
// White with alpha (0xRRGGBBAA)
const alpha = Math.floor(primitive.alpha * 255);
colors[i] = (255 << 24) | (255 << 16) | (255 << 8) | alpha;
}
// Clip rect (use first primitive's clip rect for the batch)
if (!clipRect && primitive.clipRect) {
clipRect = {
x: primitive.clipRect.x,
y: primitive.clipRect.y,
width: primitive.clipRect.width,
height: primitive.clipRect.height
};
}
}
return {
transforms,
textureIds,
uvs,
colors,
tileCount,
sortingLayer: this._sortingLayer,
orderInLayer: this._orderInLayer,
bScreenSpace: true, // FairyGUI always renders in screen space
clipRect
};
}
}
/**
* Create a default FGUI render data provider
* FGUI
*/
export function createFGUIRenderDataProvider(): FGUIRenderDataProvider {
return new FGUIRenderDataProvider();
}