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:
547
packages/rendering/fairygui/src/render/Canvas2DBackend.ts
Normal file
547
packages/rendering/fairygui/src/render/Canvas2DBackend.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
577
packages/rendering/fairygui/src/render/DOMTextRenderer.ts
Normal file
577
packages/rendering/fairygui/src/render/DOMTextRenderer.ts
Normal 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
|
||||
*
|
||||
* 预览模式(true):UI 使用屏幕空间叠加,固定在屏幕上
|
||||
* 编辑器模式(false):UI 在世界空间渲染,跟随编辑器相机
|
||||
*/
|
||||
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;
|
||||
}
|
||||
1183
packages/rendering/fairygui/src/render/FGUIRenderDataProvider.ts
Normal file
1183
packages/rendering/fairygui/src/render/FGUIRenderDataProvider.ts
Normal file
File diff suppressed because it is too large
Load Diff
480
packages/rendering/fairygui/src/render/GraphMeshGenerator.ts
Normal file
480
packages/rendering/fairygui/src/render/GraphMeshGenerator.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
140
packages/rendering/fairygui/src/render/IRenderBackend.ts
Normal file
140
packages/rendering/fairygui/src/render/IRenderBackend.ts
Normal 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;
|
||||
287
packages/rendering/fairygui/src/render/IRenderCollector.ts
Normal file
287
packages/rendering/fairygui/src/render/IRenderCollector.ts
Normal 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[];
|
||||
}
|
||||
310
packages/rendering/fairygui/src/render/RenderBridge.ts
Normal file
310
packages/rendering/fairygui/src/render/RenderBridge.ts
Normal 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)
|
||||
};
|
||||
}
|
||||
}
|
||||
136
packages/rendering/fairygui/src/render/RenderCollector.ts
Normal file
136
packages/rendering/fairygui/src/render/RenderCollector.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user