refactor: reorganize package structure and decouple framework packages (#338)

* refactor: reorganize package structure and decouple framework packages

## Package Structure Reorganization
- Reorganized 55 packages into categorized subdirectories:
  - packages/framework/ - Generic framework (Laya/Cocos compatible)
  - packages/engine/ - ESEngine core modules
  - packages/rendering/ - Rendering modules (WASM dependent)
  - packages/physics/ - Physics modules
  - packages/streaming/ - World streaming
  - packages/network-ext/ - Network extensions
  - packages/editor/ - Editor framework and plugins
  - packages/rust/ - Rust WASM engine
  - packages/tools/ - Build tools and SDK

## Framework Package Decoupling
- Decoupled behavior-tree and blueprint packages from ESEngine dependencies
- Created abstracted interfaces (IBTAssetManager, IBehaviorTreeAssetContent)
- ESEngine-specific code moved to esengine/ subpath exports
- Framework packages now usable with Cocos/Laya without ESEngine

## CI Configuration
- Updated CI to only type-check and lint framework packages
- Added type-check:framework and lint:framework scripts

## Breaking Changes
- Package import paths changed due to directory reorganization
- ESEngine integrations now use subpath imports (e.g., '@esengine/behavior-tree/esengine')

* fix: update es-engine file path after directory reorganization

* docs: update README to focus on framework over engine

* ci: only build framework packages, remove Rust/WASM dependencies

* fix: remove esengine subpath from behavior-tree and blueprint builds

ESEngine integration code will only be available in full engine builds.
Framework packages are now purely engine-agnostic.

* fix: move network-protocols to framework, build both in CI

* fix: update workflow paths from packages/core to packages/framework/core

* fix: exclude esengine folder from type-check in behavior-tree and blueprint

* fix: update network tsconfig references to new paths

* fix: add test:ci:framework to only test framework packages in CI

* fix: only build core and math npm packages in CI

* fix: exclude test files from CodeQL and fix string escaping security issue
This commit is contained in:
YHH
2025-12-26 14:50:35 +08:00
committed by GitHub
parent a84ff902e4
commit 155411e743
1936 changed files with 4147 additions and 11578 deletions

View File

@@ -0,0 +1,547 @@
import type { IRectangle } from '../utils/MathTypes';
import type { IRenderPrimitive, ETextAlign, ETextVAlign } from './IRenderCollector';
import { ERenderPrimitiveType } from './IRenderCollector';
import type { IRenderBackend, IRenderStats, ITextureHandle, IFontHandle } from './IRenderBackend';
/**
* Canvas2D texture handle
* Canvas2D 纹理句柄
*/
class Canvas2DTexture implements ITextureHandle {
private static _nextId = 1;
public readonly id: number;
public readonly width: number;
public readonly height: number;
public readonly source: ImageBitmap | HTMLImageElement | HTMLCanvasElement;
private _valid: boolean = true;
constructor(source: ImageBitmap | HTMLImageElement | HTMLCanvasElement) {
this.id = Canvas2DTexture._nextId++;
this.source = source;
this.width = source.width;
this.height = source.height;
}
public get isValid(): boolean {
return this._valid;
}
public invalidate(): void {
this._valid = false;
}
}
/**
* Canvas2D font handle
* Canvas2D 字体句柄
*/
class Canvas2DFont implements IFontHandle {
public readonly family: string;
private _loaded: boolean = false;
constructor(family: string) {
this.family = family;
}
public get isLoaded(): boolean {
return this._loaded;
}
public setLoaded(): void {
this._loaded = true;
}
}
/**
* Canvas2DBackend
*
* Canvas 2D rendering backend for FairyGUI.
* Provides fallback rendering when WebGPU is not available.
*
* Canvas 2D 渲染后端
* 在 WebGPU 不可用时提供回退渲染
*/
export class Canvas2DBackend implements IRenderBackend {
public readonly name = 'Canvas2D';
private _canvas: HTMLCanvasElement | null = null;
private _ctx: CanvasRenderingContext2D | null = null;
private _width: number = 0;
private _height: number = 0;
private _initialized: boolean = false;
private _textures: Map<number, Canvas2DTexture> = new Map();
private _clipRect: IRectangle | null = null;
private _stats: IRenderStats = {
drawCalls: 0,
triangles: 0,
textureSwitches: 0,
batches: 0,
frameTime: 0
};
private _frameStartTime: number = 0;
private _lastTextureId: number = -1;
public get isInitialized(): boolean {
return this._initialized;
}
public get width(): number {
return this._width;
}
public get height(): number {
return this._height;
}
public async initialize(canvas: HTMLCanvasElement): Promise<boolean> {
this._canvas = canvas;
this._ctx = canvas.getContext('2d', {
alpha: true,
desynchronized: true
});
if (!this._ctx) {
console.error('Failed to get Canvas 2D context');
return false;
}
this._width = canvas.width;
this._height = canvas.height;
this._initialized = true;
return true;
}
public beginFrame(): void {
if (!this._ctx) return;
this._frameStartTime = performance.now();
this._stats.drawCalls = 0;
this._stats.triangles = 0;
this._stats.textureSwitches = 0;
this._stats.batches = 0;
this._lastTextureId = -1;
// Clear canvas
this._ctx.setTransform(1, 0, 0, 1, 0, 0);
this._ctx.clearRect(0, 0, this._width, this._height);
}
public endFrame(): void {
this._stats.frameTime = performance.now() - this._frameStartTime;
}
public submitPrimitives(primitives: readonly IRenderPrimitive[]): void {
if (!this._ctx || primitives.length === 0) return;
this._stats.batches++;
for (const primitive of primitives) {
this.renderPrimitive(primitive);
}
}
public setClipRect(rect: IRectangle | null): void {
if (!this._ctx) return;
this._clipRect = rect;
this._ctx.restore();
this._ctx.save();
if (rect) {
this._ctx.beginPath();
this._ctx.rect(rect.x, rect.y, rect.width, rect.height);
this._ctx.clip();
}
}
public createTexture(
source: ImageBitmap | HTMLImageElement | HTMLCanvasElement | ImageData
): ITextureHandle {
let textureSource: ImageBitmap | HTMLImageElement | HTMLCanvasElement;
if (source instanceof ImageData) {
// Convert ImageData to canvas
const canvas = document.createElement('canvas');
canvas.width = source.width;
canvas.height = source.height;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.putImageData(source, 0, 0);
}
textureSource = canvas;
} else {
textureSource = source;
}
const texture = new Canvas2DTexture(textureSource);
this._textures.set(texture.id, texture);
return texture;
}
public destroyTexture(texture: ITextureHandle): void {
const cached = this._textures.get(texture.id);
if (cached) {
cached.invalidate();
this._textures.delete(texture.id);
}
}
public async loadFont(family: string, url?: string): Promise<IFontHandle> {
const font = new Canvas2DFont(family);
if (url) {
try {
const fontFace = new FontFace(family, `url(${url})`);
await fontFace.load();
// Use type assertion for FontFaceSet.add which exists in browsers
(document.fonts as unknown as { add(font: FontFace): void }).add(fontFace);
font.setLoaded();
} catch (error) {
console.error(`Failed to load font: ${family}`, error);
}
} else {
// Assume system font is available
font.setLoaded();
}
return font;
}
public resize(width: number, height: number): void {
if (!this._canvas) return;
this._canvas.width = width;
this._canvas.height = height;
this._width = width;
this._height = height;
}
public getStats(): IRenderStats {
return { ...this._stats };
}
public dispose(): void {
for (const texture of this._textures.values()) {
texture.invalidate();
}
this._textures.clear();
this._ctx = null;
this._canvas = null;
this._initialized = false;
}
private renderPrimitive(primitive: IRenderPrimitive): void {
if (!this._ctx) return;
const ctx = this._ctx;
const textureId = typeof primitive.textureId === 'number' ? primitive.textureId : -1;
// Track texture switches
if (textureId !== -1 && textureId !== this._lastTextureId) {
this._stats.textureSwitches++;
this._lastTextureId = textureId;
}
// Apply transform
ctx.save();
ctx.globalAlpha = primitive.alpha ?? 1;
if (primitive.transform) {
const t = primitive.transform;
ctx.setTransform(t.a, t.b, t.c, t.d, t.tx, t.ty);
}
switch (primitive.type) {
case ERenderPrimitiveType.Image:
this.renderImage(primitive);
break;
case ERenderPrimitiveType.Text:
this.renderText(primitive);
break;
case ERenderPrimitiveType.Rect:
this.renderRect(primitive);
break;
case ERenderPrimitiveType.Ellipse:
this.renderEllipse(primitive);
break;
case ERenderPrimitiveType.Polygon:
this.renderPolygon(primitive);
break;
case ERenderPrimitiveType.Graph:
// Handle graph type based on graphType property
this.renderGraph(primitive);
break;
}
ctx.restore();
this._stats.drawCalls++;
}
private renderImage(primitive: IRenderPrimitive): void {
if (!this._ctx) return;
const textureId = typeof primitive.textureId === 'number' ? primitive.textureId : -1;
if (textureId === -1) return;
const texture = this._textures.get(textureId);
if (!texture || !texture.isValid) return;
const x = primitive.x ?? 0;
const y = primitive.y ?? 0;
const width = primitive.width ?? texture.width;
const height = primitive.height ?? texture.height;
const srcRect = primitive.srcRect;
if (srcRect) {
this._ctx.drawImage(
texture.source,
srcRect.x,
srcRect.y,
srcRect.width,
srcRect.height,
x,
y,
width,
height
);
} else {
this._ctx.drawImage(texture.source, x, y, width, height);
}
this._stats.triangles += 2;
}
private renderText(primitive: IRenderPrimitive): void {
if (!this._ctx || !primitive.text) return;
const ctx = this._ctx;
const x = primitive.x ?? 0;
const y = primitive.y ?? 0;
const text = primitive.text;
const font = primitive.font ?? 'Arial';
const fontSize = primitive.fontSize ?? 14;
const color = primitive.color ?? 0x000000;
const textAlign = primitive.textAlign ?? primitive.align ?? 'left';
const textVAlign = primitive.textVAlign ?? primitive.valign ?? 'top';
const width = primitive.width;
const height = primitive.height;
ctx.font = `${fontSize}px ${font}`;
ctx.fillStyle = this.colorToCSS(color);
ctx.textBaseline = this.mapVAlign(String(textVAlign));
ctx.textAlign = this.mapHAlign(String(textAlign));
let drawX = x;
let drawY = y;
if (width !== undefined) {
if (textAlign === 'center') drawX = x + width / 2;
else if (textAlign === 'right') drawX = x + width;
}
if (height !== undefined) {
if (textVAlign === 'middle') drawY = y + height / 2;
else if (textVAlign === 'bottom') drawY = y + height;
}
ctx.fillText(text, drawX, drawY);
}
private renderRect(primitive: IRenderPrimitive): void {
if (!this._ctx) return;
const ctx = this._ctx;
const x = primitive.x ?? 0;
const y = primitive.y ?? 0;
const width = primitive.width ?? 0;
const height = primitive.height ?? 0;
const color = primitive.color ?? primitive.fillColor;
const lineColor = primitive.lineColor ?? primitive.strokeColor;
const lineWidth = primitive.lineWidth ?? primitive.strokeWidth ?? 1;
if (color !== undefined) {
ctx.fillStyle = this.colorToCSS(color);
ctx.fillRect(x, y, width, height);
}
if (lineColor !== undefined) {
ctx.strokeStyle = this.colorToCSS(lineColor);
ctx.lineWidth = lineWidth;
ctx.strokeRect(x, y, width, height);
}
this._stats.triangles += 2;
}
private renderEllipse(primitive: IRenderPrimitive): void {
if (!this._ctx) return;
const ctx = this._ctx;
const x = primitive.x ?? 0;
const y = primitive.y ?? 0;
const width = primitive.width ?? 0;
const height = primitive.height ?? 0;
const color = primitive.color ?? primitive.fillColor;
const lineColor = primitive.lineColor ?? primitive.strokeColor;
const lineWidth = primitive.lineWidth ?? primitive.strokeWidth ?? 1;
const cx = x + width / 2;
const cy = y + height / 2;
const rx = width / 2;
const ry = height / 2;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
if (color !== undefined) {
ctx.fillStyle = this.colorToCSS(color);
ctx.fill();
}
if (lineColor !== undefined) {
ctx.strokeStyle = this.colorToCSS(lineColor);
ctx.lineWidth = lineWidth;
ctx.stroke();
}
// Approximate triangle count for ellipse
this._stats.triangles += 32;
}
private renderPolygon(primitive: IRenderPrimitive): void {
const points = primitive.points ?? primitive.polygonPoints;
if (!this._ctx || !points || points.length < 4) return;
const ctx = this._ctx;
const color = primitive.color ?? primitive.fillColor;
const lineColor = primitive.lineColor ?? primitive.strokeColor;
const lineWidth = primitive.lineWidth ?? primitive.strokeWidth ?? 1;
ctx.beginPath();
ctx.moveTo(points[0], points[1]);
for (let i = 2; i < points.length; i += 2) {
ctx.lineTo(points[i], points[i + 1]);
}
ctx.closePath();
if (color !== undefined) {
ctx.fillStyle = this.colorToCSS(color);
ctx.fill();
}
if (lineColor !== undefined) {
ctx.strokeStyle = this.colorToCSS(lineColor);
ctx.lineWidth = lineWidth;
ctx.stroke();
}
this._stats.triangles += Math.max(0, points.length / 2 - 2);
}
private renderGraph(primitive: IRenderPrimitive): void {
// Render based on graphType
const graphType = primitive.graphType;
if (graphType === undefined) return;
// For now, delegate to rect/ellipse/polygon based on type
switch (graphType) {
case 0: // Rect
this.renderRect(primitive);
break;
case 1: // Ellipse
this.renderEllipse(primitive);
break;
case 2: // Polygon
this.renderPolygon(primitive);
break;
case 3: // Regular Polygon
this.renderRegularPolygon(primitive);
break;
}
}
private renderRegularPolygon(primitive: IRenderPrimitive): void {
if (!this._ctx) return;
const ctx = this._ctx;
const x = primitive.x ?? 0;
const y = primitive.y ?? 0;
const width = primitive.width ?? 0;
const height = primitive.height ?? 0;
const sides = primitive.sides ?? 6;
const startAngle = (primitive.startAngle ?? 0) * Math.PI / 180;
const color = primitive.color ?? primitive.fillColor;
const lineColor = primitive.lineColor ?? primitive.strokeColor;
const lineWidth = primitive.lineWidth ?? primitive.strokeWidth ?? 1;
const cx = x + width / 2;
const cy = y + height / 2;
const rx = width / 2;
const ry = height / 2;
ctx.beginPath();
for (let i = 0; i < sides; i++) {
const angle = startAngle + (i * 2 * Math.PI) / sides;
const px = cx + Math.cos(angle) * rx;
const py = cy + Math.sin(angle) * ry;
if (i === 0) {
ctx.moveTo(px, py);
} else {
ctx.lineTo(px, py);
}
}
ctx.closePath();
if (color !== undefined) {
ctx.fillStyle = this.colorToCSS(color);
ctx.fill();
}
if (lineColor !== undefined) {
ctx.strokeStyle = this.colorToCSS(lineColor);
ctx.lineWidth = lineWidth;
ctx.stroke();
}
this._stats.triangles += sides;
}
/**
* Convert packed color (0xRRGGBBAA) to CSS rgba string
* 将打包颜色0xRRGGBBAA转换为 CSS rgba 字符串
*/
private colorToCSS(color: number): string {
const r = (color >> 24) & 0xff;
const g = (color >> 16) & 0xff;
const b = (color >> 8) & 0xff;
const a = (color & 0xff) / 255;
return `rgba(${r},${g},${b},${a})`;
}
private mapHAlign(align: ETextAlign | string | undefined): CanvasTextAlign {
switch (align) {
case 'center':
return 'center';
case 'right':
return 'right';
default:
return 'left';
}
}
private mapVAlign(align: ETextVAlign | string | undefined): CanvasTextBaseline {
switch (align) {
case 'middle':
return 'middle';
case 'bottom':
return 'bottom';
default:
return 'top';
}
}
}

View File

@@ -0,0 +1,577 @@
/**
* DOMTextRenderer
*
* Renders FGUI text primitives using HTML DOM elements.
* This provides text rendering when the engine doesn't support native text rendering.
*
* 使用 HTML DOM 元素渲染 FGUI 文本图元
* 当引擎不支持原生文本渲染时提供文本渲染能力
*
* Coordinate systems:
* - FGUI coordinate: top-left origin (0,0), Y-down
* - Engine world coordinate: center origin (0,0), Y-up
* - DOM coordinate: top-left origin, Y-down
*
* Editor mode: UI renders in world space, follows editor camera
* Preview mode: UI renders in screen space, fixed overlay
*
* 坐标系:
* - FGUI 坐标:左上角原点 (0,0)Y 向下
* - 引擎世界坐标:中心原点 (0,0)Y 向上
* - DOM 坐标左上角原点Y 向下
*
* 编辑器模式UI 在世界空间渲染,跟随编辑器相机
* 预览模式UI 在屏幕空间渲染,固定覆盖层
*/
import type { IRenderPrimitive } from './IRenderCollector';
import { ERenderPrimitiveType } from './IRenderCollector';
import { EAlignType, EVertAlignType } from '../core/FieldTypes';
/**
* Camera state for coordinate conversion
* 相机状态,用于坐标转换
*/
export interface ICameraState {
x: number;
y: number;
zoom: number;
rotation?: number;
}
/**
* Text element pool entry
* 文本元素池条目
*/
interface TextElement {
element: HTMLDivElement;
inUse: boolean;
primitiveHash: string;
}
/**
* DOMTextRenderer
*
* Manages a pool of HTML elements for text rendering.
* 管理用于文本渲染的 HTML 元素池
*/
export class DOMTextRenderer {
/** Container element | 容器元素 */
private _container: HTMLDivElement | null = null;
/** Text element pool | 文本元素池 */
private _elementPool: TextElement[] = [];
/** Current frame elements in use | 当前帧使用的元素数量 */
private _elementsInUse: number = 0;
/** Canvas reference for coordinate conversion | 画布引用,用于坐标转换 */
private _canvas: HTMLCanvasElement | null = null;
/** Design width | 设计宽度 */
private _designWidth: number = 1920;
/** Design height | 设计高度 */
private _designHeight: number = 1080;
/** Whether initialized | 是否已初始化 */
private _initialized: boolean = false;
/** Preview mode (screen space) vs Editor mode (world space) | 预览模式屏幕空间vs 编辑器模式(世界空间) */
private _previewMode: boolean = false;
/** Camera state for editor mode | 编辑器模式的相机状态 */
private _camera: ICameraState = { x: 0, y: 0, zoom: 1 };
/**
* Initialize the renderer
* 初始化渲染器
*/
public initialize(canvas: HTMLCanvasElement): void {
if (this._initialized) return;
this._canvas = canvas;
// Create container overlay that matches canvas exactly
// 使用 fixed 定位,这样可以直接使用 getBoundingClientRect 的坐标
// Use fixed positioning so we can directly use getBoundingClientRect coordinates
this._container = document.createElement('div');
this._container.id = 'fgui-text-container';
this._container.style.cssText = `
position: fixed;
pointer-events: none;
overflow: hidden;
z-index: 9999;
`;
// Append to body for fixed positioning
// 附加到 body 以使用 fixed 定位
document.body.appendChild(this._container);
this._initialized = true;
}
/**
* Set design size for coordinate conversion
* 设置设计尺寸,用于坐标转换
*/
public setDesignSize(width: number, height: number): void {
this._designWidth = width;
this._designHeight = height;
}
/**
* Set preview mode
* 设置预览模式
*
* In preview mode (true): UI uses screen space overlay, fixed on screen
* In editor mode (false): UI renders in world space, follows editor camera
*
* 预览模式trueUI 使用屏幕空间叠加,固定在屏幕上
* 编辑器模式falseUI 在世界空间渲染,跟随编辑器相机
*/
public setPreviewMode(mode: boolean): void {
this._previewMode = mode;
}
/**
* Set camera state for editor mode
* 设置编辑器模式的相机状态
*/
public setCamera(camera: ICameraState): void {
this._camera = camera;
}
/**
* Begin a new frame
* 开始新的一帧
*/
public beginFrame(): void {
// Mark all elements as not in use
for (const entry of this._elementPool) {
entry.inUse = false;
}
this._elementsInUse = 0;
}
/**
* Render text primitives
* 渲染文本图元
*/
public renderPrimitives(primitives: readonly IRenderPrimitive[]): void {
if (!this._container || !this._canvas) {
// Try to auto-initialize if not done yet
// 如果尚未初始化,尝试自动初始化
if (!this._initialized) {
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
if (canvas) {
this.initialize(canvas);
}
}
if (!this._container || !this._canvas) return;
}
// Get canvas position and size
// 获取 canvas 位置和尺寸
const canvasRect = this._canvas.getBoundingClientRect();
// Update container to match canvas position
// 更新容器以匹配 canvas 位置
this._container.style.left = `${canvasRect.left}px`;
this._container.style.top = `${canvasRect.top}px`;
this._container.style.width = `${canvasRect.width}px`;
this._container.style.height = `${canvasRect.height}px`;
for (const primitive of primitives) {
if (primitive.type !== ERenderPrimitiveType.Text) continue;
if (!primitive.text) continue;
if (this._previewMode) {
// Preview mode: Screen space rendering
// 预览模式:屏幕空间渲染
this.renderTextPrimitiveScreenSpace(primitive, canvasRect);
} else {
// Editor mode: World space rendering with camera transform
// 编辑器模式:应用相机变换的世界空间渲染
this.renderTextPrimitiveWorldSpace(primitive, canvasRect);
}
}
}
/**
* Render text in screen space (preview mode)
* 在屏幕空间渲染文本(预览模式)
*/
private renderTextPrimitiveScreenSpace(primitive: IRenderPrimitive, canvasRect: DOMRect): void {
// Calculate scale from design resolution to actual canvas size
// 计算从设计分辨率到实际画布尺寸的缩放
const scaleX = canvasRect.width / this._designWidth;
const scaleY = canvasRect.height / this._designHeight;
const scale = Math.min(scaleX, scaleY);
// Calculate offset to center the UI (when aspect ratios don't match)
// 计算居中 UI 的偏移量(当宽高比不匹配时)
const offsetX = (canvasRect.width - this._designWidth * scale) / 2;
const offsetY = (canvasRect.height - this._designHeight * scale) / 2;
this.renderTextPrimitive(primitive, scale, offsetX, offsetY, scale);
}
/**
* Render text in world space (editor mode)
* 在世界空间渲染文本(编辑器模式)
*
* Coordinate conversion:
* 1. FGUI coordinates (top-left origin, Y-down) -> Engine world coordinates (center origin, Y-up)
* 2. Apply camera transform (pan and zoom)
* 3. Engine screen coordinates -> DOM coordinates (top-left origin, Y-down)
*
* 坐标转换:
* 1. FGUI 坐标左上角原点Y向下 -> 引擎世界坐标中心原点Y向上
* 2. 应用相机变换(平移和缩放)
* 3. 引擎屏幕坐标 -> DOM 坐标左上角原点Y向下
*/
private renderTextPrimitiveWorldSpace(primitive: IRenderPrimitive, canvasRect: DOMRect): void {
const element = this.getOrCreateElement();
// Get FGUI position from world matrix
// FGUI coordinates: top-left origin, Y-down
const m = primitive.worldMatrix;
const fguiX = m ? m[4] : 0;
const fguiY = m ? m[5] : 0;
// Extract scale from matrix (same as FGUIRenderDataProvider)
// 从矩阵提取缩放(与 FGUIRenderDataProvider 相同)
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;
// Convert FGUI coordinates to engine world coordinates (same as FGUIRenderDataProvider)
// FGUI: (0,0) = top-left, Y-down
// Engine: (0,0) = center, Y-up
// 使用与 FGUIRenderDataProvider 相同的坐标转换逻辑
const halfDesignWidth = this._designWidth / 2;
const halfDesignHeight = this._designHeight / 2;
// Engine world coordinates
// 引擎世界坐标
const worldX = fguiX - halfDesignWidth;
const worldY = halfDesignHeight - fguiY;
// Apply camera transform (pan and zoom)
// The engine applies camera to sprites; we need to do the same for DOM text
// 应用相机变换(平移和缩放)
// 引擎对精灵应用相机变换;我们需要对 DOM 文本做同样处理
const viewX = (worldX - this._camera.x) * this._camera.zoom;
const viewY = (worldY - this._camera.y) * this._camera.zoom;
// Convert to DOM screen coordinates
// Screen center is at (canvasWidth/2, canvasHeight/2)
// Engine Y-up -> DOM Y-down
// 转换为 DOM 屏幕坐标
const screenX = canvasRect.width / 2 + viewX;
const screenY = canvasRect.height / 2 - viewY;
// Calculate size with matrix scale and camera zoom
// 使用矩阵缩放和相机缩放计算尺寸
const width = primitive.width * matrixScaleX * this._camera.zoom;
const height = primitive.height * matrixScaleY * this._camera.zoom;
const fontSize = (primitive.fontSize ?? 12) * matrixScaleY * this._camera.zoom;
// Build style
const style = element.style;
style.display = 'block';
style.position = 'absolute';
style.left = `${screenX}px`;
style.top = `${screenY}px`;
style.width = `${width}px`;
style.height = `${height}px`;
style.fontSize = `${fontSize}px`;
style.fontFamily = primitive.font || 'Arial, sans-serif';
style.color = this.colorToCSS(primitive.color ?? 0xFFFFFFFF);
style.opacity = String(primitive.alpha ?? 1);
style.overflow = 'hidden';
// Text wrapping (world space mode):
// - singleLine: no wrap at all (nowrap)
// - wordWrap: wrap at word boundaries when exceeding width (pre-wrap)
// - neither: preserve whitespace but no auto-wrap (pre)
// 文本换行(世界空间模式):
// - singleLine: 完全不换行 (nowrap)
// - wordWrap: 超出宽度时在单词边界换行 (pre-wrap)
// - 都不是: 保留空白但不自动换行 (pre)
if (primitive.singleLine) {
style.whiteSpace = 'nowrap';
style.wordBreak = 'normal';
} else if (primitive.wordWrap) {
style.whiteSpace = 'pre-wrap';
style.wordBreak = 'break-word';
} else {
style.whiteSpace = 'pre';
style.wordBreak = 'normal';
}
// Combined scale factor for consistent sizing
// 统一的缩放因子以保持一致性
const sizeScale = matrixScaleY * this._camera.zoom;
style.lineHeight = `${fontSize + (primitive.leading ?? 0) * sizeScale}px`;
style.letterSpacing = `${(primitive.letterSpacing ?? 0) * sizeScale}px`;
// Text decoration
const decorations: string[] = [];
if (primitive.underline) decorations.push('underline');
style.textDecoration = decorations.join(' ') || 'none';
// Font style
style.fontWeight = primitive.bold ? 'bold' : 'normal';
style.fontStyle = primitive.italic ? 'italic' : 'normal';
// Text alignment
style.textAlign = this.mapHAlign(primitive.align as EAlignType);
style.display = 'flex';
style.alignItems = this.mapVAlignFlex(primitive.valign as EVertAlignType);
style.justifyContent = this.mapHAlignFlex(primitive.align as EAlignType);
// Text stroke (using text-shadow for approximation)
if (primitive.stroke && primitive.stroke > 0) {
const strokeColor = this.colorToCSS(primitive.strokeColor ?? 0x000000FF);
const strokeWidth = primitive.stroke * sizeScale;
style.textShadow = `
-${strokeWidth}px -${strokeWidth}px 0 ${strokeColor},
${strokeWidth}px -${strokeWidth}px 0 ${strokeColor},
-${strokeWidth}px ${strokeWidth}px 0 ${strokeColor},
${strokeWidth}px ${strokeWidth}px 0 ${strokeColor}
`;
} else {
style.textShadow = 'none';
}
// Set text content
element.textContent = primitive.text ?? '';
}
/**
* End frame - hide unused elements
* 结束帧 - 隐藏未使用的元素
*/
public endFrame(): void {
for (const entry of this._elementPool) {
if (!entry.inUse) {
entry.element.style.display = 'none';
}
}
}
/**
* Render a single text primitive (screen space mode)
* 渲染单个文本图元(屏幕空间模式)
*
* @param primitive - Text primitive to render
* @param scale - Uniform scale factor for position
* @param offsetX - X offset for centering
* @param offsetY - Y offset for centering
* @param sizeScale - Scale factor for size and font (can differ from position scale)
*/
private renderTextPrimitive(primitive: IRenderPrimitive, scale: number, offsetX: number, offsetY: number, sizeScale: number): void {
const element = this.getOrCreateElement();
// Calculate position from world matrix
// FGUI coordinates: top-left origin, Y-down
const m = primitive.worldMatrix;
let x = m ? m[4] : 0;
let y = m ? m[5] : 0;
// Apply scale and offset
x = x * scale + offsetX;
y = y * scale + offsetY;
const width = primitive.width * sizeScale;
const height = primitive.height * sizeScale;
const fontSize = (primitive.fontSize ?? 12) * sizeScale;
// Build style
const style = element.style;
style.display = 'block';
style.position = 'absolute';
style.left = `${x}px`;
style.top = `${y}px`;
style.width = `${width}px`;
style.height = `${height}px`;
style.fontSize = `${fontSize}px`;
style.fontFamily = primitive.font || 'Arial, sans-serif';
style.color = this.colorToCSS(primitive.color ?? 0xFFFFFFFF);
style.opacity = String(primitive.alpha ?? 1);
style.overflow = 'hidden';
// Text wrapping (screen space mode):
// 文本换行(屏幕空间模式)
if (primitive.singleLine) {
style.whiteSpace = 'nowrap';
style.wordBreak = 'normal';
} else if (primitive.wordWrap) {
style.whiteSpace = 'pre-wrap';
style.wordBreak = 'break-word';
} else {
style.whiteSpace = 'pre';
style.wordBreak = 'normal';
}
style.lineHeight = `${fontSize + (primitive.leading ?? 0) * sizeScale}px`;
style.letterSpacing = `${(primitive.letterSpacing ?? 0) * sizeScale}px`;
// Text decoration
const decorations: string[] = [];
if (primitive.underline) decorations.push('underline');
style.textDecoration = decorations.join(' ') || 'none';
// Font style
style.fontWeight = primitive.bold ? 'bold' : 'normal';
style.fontStyle = primitive.italic ? 'italic' : 'normal';
// Text alignment
style.textAlign = this.mapHAlign(primitive.align as EAlignType);
style.display = 'flex';
style.alignItems = this.mapVAlignFlex(primitive.valign as EVertAlignType);
style.justifyContent = this.mapHAlignFlex(primitive.align as EAlignType);
// Text stroke (using text-shadow for approximation)
if (primitive.stroke && primitive.stroke > 0) {
const strokeColor = this.colorToCSS(primitive.strokeColor ?? 0x000000FF);
const strokeWidth = primitive.stroke * sizeScale;
style.textShadow = `
-${strokeWidth}px -${strokeWidth}px 0 ${strokeColor},
${strokeWidth}px -${strokeWidth}px 0 ${strokeColor},
-${strokeWidth}px ${strokeWidth}px 0 ${strokeColor},
${strokeWidth}px ${strokeWidth}px 0 ${strokeColor}
`;
} else {
style.textShadow = 'none';
}
// Set text content
element.textContent = primitive.text ?? '';
}
/**
* Get or create a text element
* 获取或创建文本元素
*/
private getOrCreateElement(): HTMLDivElement {
// Find unused element
for (const entry of this._elementPool) {
if (!entry.inUse) {
entry.inUse = true;
this._elementsInUse++;
return entry.element;
}
}
// Create new element
const element = document.createElement('div');
element.style.pointerEvents = 'none';
this._container!.appendChild(element);
const entry: TextElement = {
element,
inUse: true,
primitiveHash: ''
};
this._elementPool.push(entry);
this._elementsInUse++;
return element;
}
/**
* Convert packed color (0xRRGGBBAA) to CSS rgba string
* 将打包颜色0xRRGGBBAA转换为 CSS rgba 字符串
*/
private colorToCSS(color: number): string {
const r = (color >> 24) & 0xff;
const g = (color >> 16) & 0xff;
const b = (color >> 8) & 0xff;
const a = (color & 0xff) / 255;
return `rgba(${r},${g},${b},${a})`;
}
/**
* Map horizontal alignment to CSS
* 将水平对齐映射到 CSS
*/
private mapHAlign(align: EAlignType | undefined): string {
switch (align) {
case EAlignType.Center:
return 'center';
case EAlignType.Right:
return 'right';
default:
return 'left';
}
}
/**
* Map horizontal alignment to flexbox
* 将水平对齐映射到 flexbox
*/
private mapHAlignFlex(align: EAlignType | undefined): string {
switch (align) {
case EAlignType.Center:
return 'center';
case EAlignType.Right:
return 'flex-end';
default:
return 'flex-start';
}
}
/**
* Map vertical alignment to flexbox
* 将垂直对齐映射到 flexbox
*/
private mapVAlignFlex(align: EVertAlignType | undefined): string {
switch (align) {
case EVertAlignType.Middle:
return 'center';
case EVertAlignType.Bottom:
return 'flex-end';
default:
return 'flex-start';
}
}
/**
* Dispose the renderer
* 释放渲染器
*/
public dispose(): void {
if (this._container && this._container.parentElement) {
this._container.parentElement.removeChild(this._container);
}
this._container = null;
this._elementPool = [];
this._initialized = false;
}
}
/**
* Default DOM text renderer instance
* 默认 DOM 文本渲染器实例
*/
let _defaultRenderer: DOMTextRenderer | null = null;
/**
* Get default DOM text renderer
* 获取默认 DOM 文本渲染器
*/
export function getDOMTextRenderer(): DOMTextRenderer {
if (!_defaultRenderer) {
_defaultRenderer = new DOMTextRenderer();
}
return _defaultRenderer;
}
/**
* Set default DOM text renderer
* 设置默认 DOM 文本渲染器
*/
export function setDOMTextRenderer(renderer: DOMTextRenderer | null): void {
_defaultRenderer = renderer;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,480 @@
/**
* GraphMeshGenerator
*
* Generates mesh data for FairyGUI graph primitives (rect, ellipse, polygon).
* Uses triangulation to convert shapes into triangles for GPU rendering.
*
* 为 FairyGUI 图形图元(矩形、椭圆、多边形)生成网格数据
* 使用三角化将形状转换为 GPU 可渲染的三角形
*/
/**
* Mesh vertex data
* 网格顶点数据
*/
export interface MeshVertex {
x: number;
y: number;
u: number;
v: number;
color: number;
}
/**
* Generated mesh data
* 生成的网格数据
*/
export interface GraphMeshData {
/** Vertex positions [x, y, ...] | 顶点位置 */
positions: number[];
/** Texture coordinates [u, v, ...] | 纹理坐标 */
uvs: number[];
/** Vertex colors (packed RGBA) | 顶点颜色 */
colors: number[];
/** Triangle indices | 三角形索引 */
indices: number[];
}
/**
* GraphMeshGenerator
*
* Generates mesh data for various graph shapes.
* 为各种图形形状生成网格数据
*/
export class GraphMeshGenerator {
/**
* Generate mesh for a filled rectangle
* 生成填充矩形的网格
*/
public static generateRect(
width: number,
height: number,
fillColor: number,
cornerRadius?: number[]
): GraphMeshData {
// Simple rectangle without corner radius
// 没有圆角的简单矩形
if (!cornerRadius || cornerRadius.every(r => r <= 0)) {
return this.generateSimpleRect(width, height, fillColor);
}
// Rectangle with corner radius - generate as polygon
// 带圆角的矩形 - 作为多边形生成
return this.generateRoundedRect(width, height, fillColor, cornerRadius);
}
/**
* Generate simple rectangle (4 vertices, 2 triangles)
* 生成简单矩形4 个顶点2 个三角形)
*/
private static generateSimpleRect(
width: number,
height: number,
color: number
): GraphMeshData {
// Vertices: top-left, top-right, bottom-right, bottom-left
const positions = [
0, 0, // top-left
width, 0, // top-right
width, height, // bottom-right
0, height // bottom-left
];
const uvs = [
0, 0,
1, 0,
1, 1,
0, 1
];
const colors = [color, color, color, color];
// Two triangles: 0-1-2, 0-2-3
const indices = [0, 1, 2, 0, 2, 3];
return { positions, uvs, colors, indices };
}
/**
* Generate rounded rectangle
* 生成圆角矩形
*/
private static generateRoundedRect(
width: number,
height: number,
color: number,
cornerRadius: number[]
): GraphMeshData {
const [tl, tr, br, bl] = cornerRadius;
const segments = 8; // Segments per corner
const points: number[] = [];
// Generate points for each corner
// Top-left corner
this.addCornerPoints(points, tl, tl, tl, Math.PI, Math.PI * 1.5, segments);
// Top-right corner
this.addCornerPoints(points, width - tr, tr, tr, Math.PI * 1.5, Math.PI * 2, segments);
// Bottom-right corner
this.addCornerPoints(points, width - br, height - br, br, 0, Math.PI * 0.5, segments);
// Bottom-left corner
this.addCornerPoints(points, bl, height - bl, bl, Math.PI * 0.5, Math.PI, segments);
// Triangulate the polygon
return this.triangulatePolygon(points, width, height, color);
}
/**
* Add corner arc points
* 添加圆角弧线点
*/
private static addCornerPoints(
points: number[],
cx: number,
cy: number,
radius: number,
startAngle: number,
endAngle: number,
segments: number
): void {
if (radius <= 0) {
points.push(cx, cy);
return;
}
const angleStep = (endAngle - startAngle) / segments;
for (let i = 0; i <= segments; i++) {
const angle = startAngle + angleStep * i;
points.push(
cx + Math.cos(angle) * radius,
cy + Math.sin(angle) * radius
);
}
}
/**
* Generate mesh for an ellipse
* 生成椭圆的网格
*/
public static generateEllipse(
width: number,
height: number,
fillColor: number
): GraphMeshData {
const radiusX = width / 2;
const radiusY = height / 2;
const centerX = radiusX;
const centerY = radiusY;
// Calculate number of segments based on perimeter
// 根据周长计算分段数
const perimeter = Math.PI * (radiusX + radiusY);
const segments = Math.min(Math.max(Math.ceil(perimeter / 4), 24), 128);
const positions: number[] = [centerX, centerY]; // Center vertex
const uvs: number[] = [0.5, 0.5]; // Center UV
const colors: number[] = [fillColor];
const indices: number[] = [];
const angleStep = (Math.PI * 2) / segments;
for (let i = 0; i <= segments; i++) {
const angle = angleStep * i;
const x = centerX + Math.cos(angle) * radiusX;
const y = centerY + Math.sin(angle) * radiusY;
positions.push(x, y);
uvs.push(
(Math.cos(angle) + 1) / 2,
(Math.sin(angle) + 1) / 2
);
colors.push(fillColor);
// Create triangle from center to edge
if (i > 0) {
indices.push(0, i, i + 1);
}
}
// Close the circle
indices.push(0, segments, 1);
return { positions, uvs, colors, indices };
}
/**
* Generate mesh for a polygon
* 生成多边形的网格
*
* Uses ear clipping algorithm for triangulation.
* 使用耳切法进行三角化
*/
public static generatePolygon(
points: number[],
width: number,
height: number,
fillColor: number
): GraphMeshData {
return this.triangulatePolygon(points, width, height, fillColor);
}
/**
* Triangulate a polygon using ear clipping algorithm
* 使用耳切法三角化多边形
*/
private static triangulatePolygon(
points: number[],
width: number,
height: number,
color: number
): GraphMeshData {
const numVertices = points.length / 2;
if (numVertices < 3) {
return { positions: [], uvs: [], colors: [], indices: [] };
}
const positions: number[] = [...points];
const uvs: number[] = [];
const colors: number[] = [];
// Generate UVs based on position
for (let i = 0; i < numVertices; i++) {
const x = points[i * 2];
const y = points[i * 2 + 1];
uvs.push(width > 0 ? x / width : 0, height > 0 ? y / height : 0);
colors.push(color);
}
// Ear clipping triangulation
const indices: number[] = [];
const restIndices: number[] = [];
for (let i = 0; i < numVertices; i++) {
restIndices.push(i);
}
while (restIndices.length > 3) {
let earFound = false;
for (let i = 0; i < restIndices.length; i++) {
const i0 = restIndices[i];
const i1 = restIndices[(i + 1) % restIndices.length];
const i2 = restIndices[(i + 2) % restIndices.length];
const ax = points[i0 * 2], ay = points[i0 * 2 + 1];
const bx = points[i1 * 2], by = points[i1 * 2 + 1];
const cx = points[i2 * 2], cy = points[i2 * 2 + 1];
// Check if this is a convex vertex (ear candidate)
if ((ay - by) * (cx - bx) + (bx - ax) * (cy - by) >= 0) {
// Check if no other point is inside this triangle
let isEar = true;
for (let j = 0; j < restIndices.length; j++) {
if (j === i || j === (i + 1) % restIndices.length || j === (i + 2) % restIndices.length) {
continue;
}
const idx = restIndices[j];
const px = points[idx * 2], py = points[idx * 2 + 1];
if (this.isPointInTriangle(px, py, ax, ay, bx, by, cx, cy)) {
isEar = false;
break;
}
}
if (isEar) {
indices.push(i0, i1, i2);
restIndices.splice((i + 1) % restIndices.length, 1);
earFound = true;
break;
}
}
}
if (!earFound) {
// No ear found, polygon may be degenerate
break;
}
}
// Add the last triangle
if (restIndices.length === 3) {
indices.push(restIndices[0], restIndices[1], restIndices[2]);
}
return { positions, uvs, colors, indices };
}
/**
* Check if point is inside triangle
* 检查点是否在三角形内
*/
private static isPointInTriangle(
px: number, py: number,
ax: number, ay: number,
bx: number, by: number,
cx: number, cy: number
): boolean {
const v0x = cx - ax, v0y = cy - ay;
const v1x = bx - ax, v1y = by - ay;
const v2x = px - ax, v2y = py - ay;
const dot00 = v0x * v0x + v0y * v0y;
const dot01 = v0x * v1x + v0y * v1y;
const dot02 = v0x * v2x + v0y * v2y;
const dot11 = v1x * v1x + v1y * v1y;
const dot12 = v1x * v2x + v1y * v2y;
const invDen = 1 / (dot00 * dot11 - dot01 * dot01);
const u = (dot11 * dot02 - dot01 * dot12) * invDen;
const v = (dot00 * dot12 - dot01 * dot02) * invDen;
return u >= 0 && v >= 0 && u + v < 1;
}
/**
* Generate outline mesh (stroke)
* 生成轮廓线网格(描边)
*/
public static generateOutline(
points: number[],
lineWidth: number,
lineColor: number,
closed: boolean = true
): GraphMeshData {
const numPoints = points.length / 2;
if (numPoints < 2) {
return { positions: [], uvs: [], colors: [], indices: [] };
}
const positions: number[] = [];
const uvs: number[] = [];
const colors: number[] = [];
const indices: number[] = [];
const halfWidth = lineWidth / 2;
for (let i = 0; i < numPoints; i++) {
const x0 = points[i * 2];
const y0 = points[i * 2 + 1];
let x1: number, y1: number;
if (i < numPoints - 1) {
x1 = points[(i + 1) * 2];
y1 = points[(i + 1) * 2 + 1];
} else if (closed) {
x1 = points[0];
y1 = points[1];
} else {
continue; // Last point, no segment
}
// Calculate perpendicular vector
const dx = x1 - x0;
const dy = y1 - y0;
const len = Math.sqrt(dx * dx + dy * dy);
if (len < 0.001) continue;
const nx = -dy / len * halfWidth;
const ny = dx / len * halfWidth;
// Add 4 vertices for this segment (quad)
const baseIdx = positions.length / 2;
positions.push(
x0 - nx, y0 - ny,
x0 + nx, y0 + ny,
x1 - nx, y1 - ny,
x1 + nx, y1 + ny
);
uvs.push(0, 0, 0, 1, 1, 0, 1, 1);
colors.push(lineColor, lineColor, lineColor, lineColor);
// Two triangles for the quad
indices.push(
baseIdx, baseIdx + 1, baseIdx + 3,
baseIdx, baseIdx + 3, baseIdx + 2
);
// Joint with previous segment
if (i > 0) {
const prevBaseIdx = baseIdx - 4;
indices.push(
prevBaseIdx + 2, prevBaseIdx + 3, baseIdx + 1,
prevBaseIdx + 2, baseIdx + 1, baseIdx
);
}
}
// Close the outline joints
if (closed && numPoints > 2) {
const lastBaseIdx = positions.length / 2 - 4;
indices.push(
lastBaseIdx + 2, lastBaseIdx + 3, 1,
lastBaseIdx + 2, 1, 0
);
}
return { positions, uvs, colors, indices };
}
/**
* Generate mesh for rectangle outline
* 生成矩形轮廓线网格
*/
public static generateRectOutline(
width: number,
height: number,
lineWidth: number,
lineColor: number,
cornerRadius?: number[]
): GraphMeshData {
const points: number[] = [];
if (!cornerRadius || cornerRadius.every(r => r <= 0)) {
// Simple rectangle
points.push(0, 0, width, 0, width, height, 0, height);
} else {
// Rounded rectangle
const [tl, tr, br, bl] = cornerRadius;
const segments = 8;
this.addCornerPoints(points, tl, tl, tl, Math.PI, Math.PI * 1.5, segments);
this.addCornerPoints(points, width - tr, tr, tr, Math.PI * 1.5, Math.PI * 2, segments);
this.addCornerPoints(points, width - br, height - br, br, 0, Math.PI * 0.5, segments);
this.addCornerPoints(points, bl, height - bl, bl, Math.PI * 0.5, Math.PI, segments);
}
return this.generateOutline(points, lineWidth, lineColor, true);
}
/**
* Generate mesh for ellipse outline
* 生成椭圆轮廓线网格
*/
public static generateEllipseOutline(
width: number,
height: number,
lineWidth: number,
lineColor: number
): GraphMeshData {
const radiusX = width / 2;
const radiusY = height / 2;
const centerX = radiusX;
const centerY = radiusY;
const perimeter = Math.PI * (radiusX + radiusY);
const segments = Math.min(Math.max(Math.ceil(perimeter / 4), 24), 128);
const points: number[] = [];
const angleStep = (Math.PI * 2) / segments;
for (let i = 0; i < segments; i++) {
const angle = angleStep * i;
points.push(
centerX + Math.cos(angle) * radiusX,
centerY + Math.sin(angle) * radiusY
);
}
return this.generateOutline(points, lineWidth, lineColor, true);
}
}

View File

@@ -0,0 +1,140 @@
import type { IRectangle } from '../utils/MathTypes';
import type { IRenderPrimitive } from './IRenderCollector';
/**
* Texture handle
* 纹理句柄
*/
export interface ITextureHandle {
/** Unique identifier | 唯一标识 */
readonly id: number;
/** Texture width | 纹理宽度 */
readonly width: number;
/** Texture height | 纹理高度 */
readonly height: number;
/** Is texture valid | 纹理是否有效 */
readonly isValid: boolean;
}
/**
* Font handle
* 字体句柄
*/
export interface IFontHandle {
/** Font family name | 字体名称 */
readonly family: string;
/** Is font loaded | 字体是否已加载 */
readonly isLoaded: boolean;
}
/**
* Render statistics
* 渲染统计
*/
export interface IRenderStats {
/** Draw call count | 绘制调用数 */
drawCalls: number;
/** Triangle count | 三角形数量 */
triangles: number;
/** Texture switches | 纹理切换次数 */
textureSwitches: number;
/** Batch count | 批次数量 */
batches: number;
/** Frame time in ms | 帧时间(毫秒) */
frameTime: number;
}
/**
* Render backend interface
*
* Abstract interface for graphics backend (WebGPU, WebGL, Canvas2D).
*
* 图形后端抽象接口WebGPU、WebGL、Canvas2D
*/
export interface IRenderBackend {
/** Backend name | 后端名称 */
readonly name: string;
/** Is backend initialized | 后端是否已初始化 */
readonly isInitialized: boolean;
/** Canvas width | 画布宽度 */
readonly width: number;
/** Canvas height | 画布高度 */
readonly height: number;
/**
* Initialize the backend
* 初始化后端
*/
initialize(canvas: HTMLCanvasElement): Promise<boolean>;
/**
* Begin a new frame
* 开始新帧
*/
beginFrame(): void;
/**
* End the current frame
* 结束当前帧
*/
endFrame(): void;
/**
* Submit render primitives for rendering
* 提交渲染图元进行渲染
*/
submitPrimitives(primitives: readonly IRenderPrimitive[]): void;
/**
* Set clip rectangle
* 设置裁剪矩形
*/
setClipRect(rect: IRectangle | null): void;
/**
* Create a texture from image data
* 从图像数据创建纹理
*/
createTexture(
source: ImageBitmap | HTMLImageElement | HTMLCanvasElement | ImageData
): ITextureHandle;
/**
* Destroy a texture
* 销毁纹理
*/
destroyTexture(texture: ITextureHandle): void;
/**
* Load a font
* 加载字体
*/
loadFont(family: string, url?: string): Promise<IFontHandle>;
/**
* Resize the backend
* 调整后端大小
*/
resize(width: number, height: number): void;
/**
* Get render statistics
* 获取渲染统计
*/
getStats(): IRenderStats;
/**
* Dispose the backend
* 销毁后端
*/
dispose(): void;
}
/**
* Backend factory function type
* 后端工厂函数类型
*/
export type RenderBackendFactory = () => IRenderBackend;

View File

@@ -0,0 +1,287 @@
import type { IRectangle } from '../utils/MathTypes';
import type { EGraphType, EAlignType, EVertAlignType } from '../core/FieldTypes';
/**
* Render primitive type
* 渲染图元类型
*/
export const enum ERenderPrimitiveType {
Rect = 'rect',
Image = 'image',
Text = 'text',
Mesh = 'mesh',
Graph = 'graph',
Ellipse = 'ellipse',
Polygon = 'polygon'
}
/**
* Blend mode
* 混合模式
*/
export const enum EBlendModeType {
Normal = 'normal',
Add = 'add',
Multiply = 'multiply',
Screen = 'screen'
}
/**
* Transform matrix (2D affine)
* 变换矩阵2D 仿射)
*/
export interface ITransformMatrix {
a: number;
b: number;
c: number;
d: number;
tx: number;
ty: number;
}
/**
* Text alignment
* 文本对齐
*/
export const enum ETextAlign {
Left = 'left',
Center = 'center',
Right = 'right'
}
/**
* Text vertical alignment
* 文本垂直对齐
*/
export const enum ETextVAlign {
Top = 'top',
Middle = 'middle',
Bottom = 'bottom'
}
/**
* Render primitive data
* 渲染图元数据
*/
export interface IRenderPrimitive {
/** Primitive type | 图元类型 */
type: ERenderPrimitiveType;
/** Sort order (higher = on top) | 排序顺序(越大越上层) */
sortOrder: number;
/** World matrix (6 elements: a, b, c, d, tx, ty) | 世界矩阵 */
worldMatrix: Float32Array;
/** X position | X 坐标 */
x?: number;
/** Y position | Y 坐标 */
y?: number;
/** Width | 宽度 */
width: number;
/** Height | 高度 */
height: number;
/** Alpha | 透明度 */
alpha: number;
/** Is grayed | 是否灰度 */
grayed: boolean;
/** Transform matrix | 变换矩阵 */
transform?: ITransformMatrix;
/** Blend mode | 混合模式 */
blendMode?: EBlendModeType;
/** Clip rect (in stage coordinates) | 裁剪矩形(舞台坐标) */
clipRect?: IRectangle;
/** Source rectangle (for image) | 源矩形(用于图像) */
srcRect?: IRectangle;
// Image properties | 图像属性
/** Texture ID or key | 纹理 ID 或键 */
textureId?: string | number;
/** UV rect [u, v, uWidth, vHeight] | UV 矩形 */
uvRect?: [number, number, number, number];
/** Tint color (RGBA packed) | 着色颜色 */
color?: number;
/** Nine-patch grid | 九宫格 */
scale9Grid?: IRectangle;
/** Source width for nine-slice (original texture region width) | 九宫格源宽度(原始纹理区域宽度) */
sourceWidth?: number;
/** Source height for nine-slice (original texture region height) | 九宫格源高度(原始纹理区域高度) */
sourceHeight?: number;
/** Tile mode | 平铺模式 */
tileMode?: boolean;
// Text properties | 文本属性
/** Text content | 文本内容 */
text?: string;
/** Font family | 字体 */
font?: string;
/** Font size | 字体大小 */
fontSize?: number;
/** Text color | 文本颜色 */
textColor?: number;
/** Bold | 粗体 */
bold?: boolean;
/** Italic | 斜体 */
italic?: boolean;
/** Underline | 下划线 */
underline?: boolean;
/** Text align | 文本对齐 */
align?: ETextAlign | EAlignType;
/** Text horizontal align (alias) | 文本水平对齐(别名) */
textAlign?: ETextAlign | string;
/** Text vertical align | 文本垂直对齐 */
valign?: ETextVAlign | EVertAlignType;
/** Text vertical align (alias) | 文本垂直对齐(别名) */
textVAlign?: ETextVAlign | string;
/** Leading (line spacing) | 行间距 */
leading?: number;
/** Letter spacing | 字间距 */
letterSpacing?: number;
/** Outline color | 描边颜色 */
outlineColor?: number;
/** Outline width | 描边宽度 */
outlineWidth?: number;
/** Shadow color | 阴影颜色 */
shadowColor?: number;
/** Shadow offset | 阴影偏移 */
shadowOffset?: [number, number];
// Rect properties | 矩形属性
/** Fill color | 填充颜色 */
fillColor?: number;
/** Stroke color | 边框颜色 */
strokeColor?: number;
/** Stroke width | 边框宽度 */
strokeWidth?: number;
/** Corner radius | 圆角半径 */
cornerRadius?: number | number[];
/** Single line | 单行 */
singleLine?: boolean;
/** Word wrap | 自动换行 */
wordWrap?: boolean;
/** Stroke | 描边宽度 */
stroke?: number;
// Graph properties | 图形属性
/** Graph type | 图形类型 */
graphType?: EGraphType;
/** Line size | 线宽 */
lineSize?: number;
/** Line color | 线颜色 */
lineColor?: number;
/** Polygon points | 多边形顶点 */
polygonPoints?: number[];
/** Points array (alias for polygonPoints) | 点数组polygonPoints 别名) */
points?: number[];
/** Line width | 线宽 */
lineWidth?: number;
/** Sides for regular polygon | 正多边形边数 */
sides?: number;
/** Start angle for regular polygon | 正多边形起始角度 */
startAngle?: number;
/** Distance multipliers for regular polygon | 正多边形距离乘数 */
distances?: number[];
// Mesh properties | 网格属性
/** Vertices [x, y, ...] | 顶点 */
vertices?: Float32Array;
/** UVs [u, v, ...] | UV 坐标 */
uvs?: Float32Array;
/** Indices | 索引 */
indices?: Uint16Array;
}
/**
* Render collector interface
* 渲染收集器接口
*/
export interface IRenderCollector {
/**
* Add a render primitive
* 添加渲染图元
*/
addPrimitive(primitive: IRenderPrimitive): void;
/**
* Push a clip rect
* 压入裁剪矩形
*/
pushClipRect(rect: IRectangle): void;
/**
* Pop the current clip rect
* 弹出当前裁剪矩形
*/
popClipRect(): void;
/**
* Get current clip rect
* 获取当前裁剪矩形
*/
getCurrentClipRect(): IRectangle | null;
/**
* Clear all primitives
* 清除所有图元
*/
clear(): void;
/**
* Get all primitives (sorted by sortOrder)
* 获取所有图元(按 sortOrder 排序)
*/
getPrimitives(): readonly IRenderPrimitive[];
}

View File

@@ -0,0 +1,310 @@
import type { IRectangle } from '../utils/MathTypes';
import type { IRenderCollector, IRenderPrimitive } from './IRenderCollector';
import type { IRenderBackend, IRenderStats, ITextureHandle, IFontHandle } from './IRenderBackend';
/**
* Texture cache entry
* 纹理缓存条目
*/
interface TextureCacheEntry {
handle: ITextureHandle;
lastUsedFrame: number;
refCount: number;
}
/**
* RenderBridge
*
* Bridges FairyGUI render primitives to the graphics backend.
* Provides batching, caching, and optimization.
*
* 将 FairyGUI 渲染图元桥接到图形后端
* 提供批处理、缓存和优化
*
* Features:
* - Automatic batching of similar primitives
* - Texture atlas support
* - Font caching
* - Render statistics
*
* @example
* ```typescript
* const bridge = new RenderBridge(webgpuBackend);
* await bridge.initialize(canvas);
*
* // In render loop
* bridge.beginFrame();
* root.collectRenderData(collector);
* bridge.render(collector);
* bridge.endFrame();
* ```
*/
export class RenderBridge {
private _backend: IRenderBackend;
private _textureCache: Map<string, TextureCacheEntry> = new Map();
private _fontCache: Map<string, IFontHandle> = new Map();
private _currentFrame: number = 0;
private _textureCacheMaxAge: number = 60; // Frames before texture is evicted
private _clipStack: IRectangle[] = [];
private _batchBuffer: IRenderPrimitive[] = [];
constructor(backend: IRenderBackend) {
this._backend = backend;
}
/**
* Get the underlying backend
* 获取底层后端
*/
public get backend(): IRenderBackend {
return this._backend;
}
/**
* Check if bridge is initialized
* 检查桥接是否已初始化
*/
public get isInitialized(): boolean {
return this._backend.isInitialized;
}
/**
* Initialize the bridge with a canvas
* 使用画布初始化桥接
*/
public async initialize(canvas: HTMLCanvasElement): Promise<boolean> {
return this._backend.initialize(canvas);
}
/**
* Begin a new frame
* 开始新帧
*/
public beginFrame(): void {
this._currentFrame++;
this._clipStack.length = 0;
this._batchBuffer.length = 0;
this._backend.beginFrame();
}
/**
* End the current frame
* 结束当前帧
*/
public endFrame(): void {
this.flushBatch();
this._backend.endFrame();
this.evictOldTextures();
}
/**
* Render primitives from a collector
* 渲染收集器中的图元
*/
public render(collector: IRenderCollector): void {
const primitives = collector.getPrimitives();
for (const primitive of primitives) {
this.processPrimitive(primitive);
}
}
/**
* Render a single primitive
* 渲染单个图元
*/
public renderPrimitive(primitive: IRenderPrimitive): void {
this.processPrimitive(primitive);
}
/**
* Push a clip rectangle
* 压入裁剪矩形
*/
public pushClipRect(rect: IRectangle): void {
if (this._clipStack.length > 0) {
const current = this._clipStack[this._clipStack.length - 1];
const intersected = this.intersectRects(current, rect);
this._clipStack.push(intersected);
} else {
this._clipStack.push({ ...rect });
}
this.flushBatch();
this._backend.setClipRect(this._clipStack[this._clipStack.length - 1]);
}
/**
* Pop the current clip rectangle
* 弹出当前裁剪矩形
*/
public popClipRect(): void {
if (this._clipStack.length > 0) {
this._clipStack.pop();
this.flushBatch();
this._backend.setClipRect(
this._clipStack.length > 0 ? this._clipStack[this._clipStack.length - 1] : null
);
}
}
/**
* Load or get cached texture
* 加载或获取缓存的纹理
*/
public async loadTexture(
url: string,
source?: ImageBitmap | HTMLImageElement | HTMLCanvasElement | ImageData
): Promise<ITextureHandle | null> {
// Check cache first
const cached = this._textureCache.get(url);
if (cached) {
cached.lastUsedFrame = this._currentFrame;
cached.refCount++;
return cached.handle;
}
// Load or create texture
let textureSource = source;
if (!textureSource) {
try {
const response = await fetch(url);
const blob = await response.blob();
textureSource = await createImageBitmap(blob);
} catch (error) {
console.error(`Failed to load texture: ${url}`, error);
return null;
}
}
const handle = this._backend.createTexture(textureSource);
this._textureCache.set(url, {
handle,
lastUsedFrame: this._currentFrame,
refCount: 1
});
return handle;
}
/**
* Release a texture reference
* 释放纹理引用
*/
public releaseTexture(url: string): void {
const cached = this._textureCache.get(url);
if (cached) {
cached.refCount--;
}
}
/**
* Load or get cached font
* 加载或获取缓存的字体
*/
public async loadFont(family: string, url?: string): Promise<IFontHandle> {
const cached = this._fontCache.get(family);
if (cached) {
return cached;
}
const handle = await this._backend.loadFont(family, url);
this._fontCache.set(family, handle);
return handle;
}
/**
* Resize the render target
* 调整渲染目标大小
*/
public resize(width: number, height: number): void {
this._backend.resize(width, height);
}
/**
* Get render statistics
* 获取渲染统计
*/
public getStats(): IRenderStats & { textureCount: number; fontCount: number } {
const backendStats = this._backend.getStats();
return {
...backendStats,
textureCount: this._textureCache.size,
fontCount: this._fontCache.size
};
}
/**
* Dispose the bridge and all resources
* 销毁桥接和所有资源
*/
public dispose(): void {
// Destroy all cached textures
for (const entry of this._textureCache.values()) {
this._backend.destroyTexture(entry.handle);
}
this._textureCache.clear();
this._fontCache.clear();
this._clipStack.length = 0;
this._batchBuffer.length = 0;
this._backend.dispose();
}
private processPrimitive(primitive: IRenderPrimitive): void {
// Check if can batch with previous primitives
if (this._batchBuffer.length > 0) {
const last = this._batchBuffer[this._batchBuffer.length - 1];
if (!this.canBatch(last, primitive)) {
this.flushBatch();
}
}
this._batchBuffer.push(primitive);
}
private canBatch(a: IRenderPrimitive, b: IRenderPrimitive): boolean {
// Can batch if same type and texture
if (a.type !== b.type) return false;
if (a.textureId !== b.textureId) return false;
if (a.blendMode !== b.blendMode) return false;
return true;
}
private flushBatch(): void {
if (this._batchBuffer.length === 0) return;
this._backend.submitPrimitives(this._batchBuffer);
this._batchBuffer.length = 0;
}
private evictOldTextures(): void {
const minFrame = this._currentFrame - this._textureCacheMaxAge;
const toEvict: string[] = [];
for (const [url, entry] of this._textureCache) {
if (entry.refCount <= 0 && entry.lastUsedFrame < minFrame) {
toEvict.push(url);
}
}
for (const url of toEvict) {
const entry = this._textureCache.get(url);
if (entry) {
this._backend.destroyTexture(entry.handle);
this._textureCache.delete(url);
}
}
}
private intersectRects(a: IRectangle, b: IRectangle): IRectangle {
const x = Math.max(a.x, b.x);
const y = Math.max(a.y, b.y);
const right = Math.min(a.x + a.width, b.x + b.width);
const bottom = Math.min(a.y + a.height, b.y + b.height);
return {
x,
y,
width: Math.max(0, right - x),
height: Math.max(0, bottom - y)
};
}
}

View File

@@ -0,0 +1,136 @@
import type { IRectangle } from '../utils/MathTypes';
import type { IRenderCollector, IRenderPrimitive } from './IRenderCollector';
/**
* RenderCollector
*
* Collects render primitives from UI hierarchy for batch rendering.
* Implements IRenderCollector interface with efficient primitive storage.
*
* 从 UI 层级收集渲染图元用于批量渲染
*/
export class RenderCollector implements IRenderCollector {
private _primitives: IRenderPrimitive[] = [];
private _clipStack: IRectangle[] = [];
private _sortNeeded: boolean = false;
/**
* Add a render primitive
* 添加渲染图元
*/
public addPrimitive(primitive: IRenderPrimitive): void {
this._primitives.push(primitive);
this._sortNeeded = true;
}
/**
* Push a clip rect onto the stack
* 压入裁剪矩形
*/
public pushClipRect(rect: IRectangle): void {
if (this._clipStack.length > 0) {
// Intersect with current clip rect
const current = this._clipStack[this._clipStack.length - 1];
const intersected = this.intersectRects(current, rect);
this._clipStack.push(intersected);
} else {
this._clipStack.push({ ...rect });
}
}
/**
* Pop the current clip rect
* 弹出当前裁剪矩形
*/
public popClipRect(): void {
if (this._clipStack.length > 0) {
this._clipStack.pop();
}
}
/**
* Get current clip rect
* 获取当前裁剪矩形
*/
public getCurrentClipRect(): IRectangle | null {
if (this._clipStack.length > 0) {
return this._clipStack[this._clipStack.length - 1];
}
return null;
}
/**
* Clear all primitives
* 清除所有图元
*/
public clear(): void {
this._primitives.length = 0;
this._clipStack.length = 0;
this._sortNeeded = false;
}
/**
* Get all primitives sorted by sortOrder
* 获取所有按 sortOrder 排序的图元
*/
public getPrimitives(): readonly IRenderPrimitive[] {
if (this._sortNeeded) {
this._primitives.sort((a, b) => a.sortOrder - b.sortOrder);
this._sortNeeded = false;
}
return this._primitives;
}
/**
* Get primitive count
* 获取图元数量
*/
public get primitiveCount(): number {
return this._primitives.length;
}
/**
* Get clip stack depth
* 获取裁剪栈深度
*/
public get clipStackDepth(): number {
return this._clipStack.length;
}
/**
* Calculate intersection of two rectangles
* 计算两个矩形的交集
*/
private intersectRects(a: IRectangle, b: IRectangle): IRectangle {
const x = Math.max(a.x, b.x);
const y = Math.max(a.y, b.y);
const right = Math.min(a.x + a.width, b.x + b.width);
const bottom = Math.min(a.y + a.height, b.y + b.height);
return {
x,
y,
width: Math.max(0, right - x),
height: Math.max(0, bottom - y)
};
}
/**
* Iterate over primitives with callback
* 遍历图元
*/
public forEach(callback: (primitive: IRenderPrimitive, index: number) => void): void {
const primitives = this.getPrimitives();
for (let i = 0; i < primitives.length; i++) {
callback(primitives[i], i);
}
}
/**
* Filter primitives by type
* 按类型过滤图元
*/
public filterByType(type: string): IRenderPrimitive[] {
return this._primitives.filter((p) => p.type === type);
}
}