Files
esengine/packages/ui/src/systems/render/UITextRenderSystem.ts

333 lines
11 KiB
TypeScript
Raw Normal View History

/**
* UI Text Render System
* UI
*
* Renders UITextComponent entities by generating text textures
* and submitting them to the shared UIRenderCollector.
* UIRenderCollector UITextComponent
*/
import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework';
import { UITransformComponent } from '../../components/UITransformComponent';
import { UITextComponent } from '../../components/UITextComponent';
import { getUIRenderCollector, registerCacheInvalidationCallback, unregisterCacheInvalidationCallback } from './UIRenderCollector';
/**
* Text texture cache entry
*
*/
interface TextTextureCache {
textureId: number;
text: string;
fontSize: number;
fontFamily: string;
fontWeight: string | number;
italic: boolean;
color: number;
alpha: number;
align: string;
verticalAlign: string;
lineHeight: number;
width: number;
height: number;
dataUrl: string;
}
/**
* UI Text Render System
* UI
*
* Handles rendering of text components by:
* 1. Generating text textures using Canvas 2D
* 2. Caching textures to avoid regeneration every frame
* 3. Submitting texture render primitives to the collector
*
*
* 1. 使 Canvas 2D
* 2.
* 3.
*/
@ECSSystem('UITextRender', { updateOrder: 120 })
export class UITextRenderSystem extends EntitySystem {
private textCanvas: HTMLCanvasElement | null = null;
private textCtx: CanvasRenderingContext2D | null = null;
private textTextureCache: Map<number, TextTextureCache> = new Map();
private nextTextureId = 90000;
private onTextureCreated: ((id: number, dataUrl: string) => void) | null = null;
private cacheInvalidationBound: () => void;
constructor() {
super(Matcher.empty().all(UITransformComponent, UITextComponent));
// Bind the method for cache invalidation callback
this.cacheInvalidationBound = this.clearTextCache.bind(this);
}
/**
* Called when system is added to scene
*
*/
public override initialize(): void {
super.initialize();
// Register for cache invalidation events
registerCacheInvalidationCallback(this.cacheInvalidationBound);
}
/**
* Called when system is destroyed
*
*/
protected override onDestroy(): void {
super.onDestroy();
// Unregister cache invalidation callback
unregisterCacheInvalidationCallback(this.cacheInvalidationBound);
}
/**
* Set callback for when a new text texture is created
*
*/
setTextureCallback(callback: (id: number, dataUrl: string) => void): void {
this.onTextureCreated = callback;
}
protected process(entities: readonly Entity[]): void {
const collector = getUIRenderCollector();
for (const entity of entities) {
const transform = entity.getComponent(UITransformComponent)!;
const text = entity.getComponent(UITextComponent)!;
if (!transform.visible || !text.text) continue;
const x = transform.worldX ?? transform.x;
const y = transform.worldY ?? transform.y;
// 使用世界缩放和旋转
const scaleX = transform.worldScaleX ?? transform.scaleX;
const scaleY = transform.worldScaleY ?? transform.scaleY;
const rotation = transform.worldRotation ?? transform.rotation;
const width = (transform.computedWidth ?? transform.width) * scaleX;
const height = (transform.computedHeight ?? transform.height) * scaleY;
const alpha = transform.worldAlpha ?? transform.alpha;
const baseOrder = 100 + transform.zIndex;
// 使用 transform 的 pivot 作为旋转/缩放中心
const pivotX = transform.pivotX;
const pivotY = transform.pivotY;
// 渲染位置 = 左下角 + pivot 偏移
const renderX = x + width * pivotX;
const renderY = y + height * pivotY;
// Generate or retrieve cached texture
// 生成或获取缓存的纹理
const textureId = this.getOrCreateTextTexture(
entity.id, text, Math.ceil(width), Math.ceil(height)
);
if (textureId === null) continue;
// Use pivot position with transform's pivot values
// 使用 transform 的 pivot 值作为旋转中心
collector.addRect(
renderX, renderY,
width, height,
0xFFFFFF, // White tint (color is baked into texture)
alpha,
baseOrder + 1, // Text renders above background
{
rotation,
pivotX,
pivotY,
textureId
}
);
}
}
/**
* Get or create text texture
*
*/
private getOrCreateTextTexture(
entityId: number,
text: UITextComponent,
width: number,
height: number
): number | null {
const canvasData = this.getTextCanvas();
if (!canvasData) return null;
const { canvas, ctx } = canvasData;
const cached = this.textTextureCache.get(entityId);
// Check if we need to regenerate the texture
// 检查是否需要重新生成纹理
const needsUpdate = !cached ||
cached.text !== text.text ||
cached.fontSize !== text.fontSize ||
cached.fontFamily !== text.fontFamily ||
cached.fontWeight !== text.fontWeight ||
cached.italic !== text.italic ||
cached.color !== text.color ||
cached.alpha !== text.alpha ||
cached.align !== text.align ||
cached.verticalAlign !== text.verticalAlign ||
cached.lineHeight !== text.lineHeight ||
cached.width !== width ||
cached.height !== height;
if (needsUpdate) {
const canvasWidth = Math.max(1, width);
const canvasHeight = Math.max(1, height);
canvas.width = canvasWidth;
canvas.height = canvasHeight;
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
ctx.font = text.getCSSFont();
ctx.fillStyle = text.getCSSColor();
ctx.textBaseline = 'top';
// Handle horizontal alignment
// 处理水平对齐
let textX = 0;
if (text.align === 'center') {
ctx.textAlign = 'center';
textX = canvasWidth / 2;
} else if (text.align === 'right') {
ctx.textAlign = 'right';
textX = canvasWidth;
} else {
ctx.textAlign = 'left';
textX = 0;
}
// Handle vertical alignment
// 处理垂直对齐
const textHeight = text.fontSize * text.lineHeight;
let textY = 0;
if (text.verticalAlign === 'middle') {
textY = (canvasHeight - textHeight) / 2;
} else if (text.verticalAlign === 'bottom') {
textY = canvasHeight - textHeight;
}
// Draw text (with or without word wrap)
// 绘制文本(带或不带自动换行)
if (text.wordWrap) {
this.drawWrappedText(ctx, text.text, textX, textY, canvasWidth, text.fontSize * text.lineHeight);
} else {
ctx.fillText(text.text, textX, textY);
}
// Get or create texture ID
// 获取或创建纹理 ID
const textureId = cached?.textureId ?? this.nextTextureId++;
const dataUrl = canvas.toDataURL('image/png');
// Notify callback of new texture
// 通知回调新纹理
if (this.onTextureCreated) {
this.onTextureCreated(textureId, dataUrl);
}
// Update cache
// 更新缓存
this.textTextureCache.set(entityId, {
textureId,
text: text.text,
fontSize: text.fontSize,
fontFamily: text.fontFamily,
fontWeight: text.fontWeight,
italic: text.italic,
color: text.color,
alpha: text.alpha,
align: text.align,
verticalAlign: text.verticalAlign,
lineHeight: text.lineHeight,
width,
height,
dataUrl
});
}
return this.textTextureCache.get(entityId)?.textureId ?? null;
}
/**
* Get or create text canvas
*
*/
private getTextCanvas(): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } | null {
if (!this.textCanvas) {
this.textCanvas = document.createElement('canvas');
this.textCtx = this.textCanvas.getContext('2d');
}
if (!this.textCtx) return null;
return { canvas: this.textCanvas, ctx: this.textCtx };
}
/**
* Draw text with word wrapping
*
*/
private drawWrappedText(
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
maxWidth: number,
lineHeight: number
): void {
const words = text.split(' ');
let line = '';
let currentY = y;
for (const word of words) {
const testLine = line + word + ' ';
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && line !== '') {
ctx.fillText(line.trim(), x, currentY);
line = word + ' ';
currentY += lineHeight;
} else {
line = testLine;
}
}
if (line.trim()) {
ctx.fillText(line.trim(), x, currentY);
}
}
/**
* Clear text texture cache
*
*/
clearTextCache(): void {
this.textTextureCache.clear();
}
/**
* Clear cache for a specific entity
*
*/
clearEntityTextCache(entityId: number): void {
this.textTextureCache.delete(entityId);
}
/**
* Dispose resources
*
*/
dispose(): void {
this.textCanvas = null;
this.textCtx = null;
this.textTextureCache.clear();
this.onTextureCreated = null;
}
}