refactor(ui): UI 系统架构重构 (#309)

* feat(ui): 动态图集系统与渲染调试增强

## 核心功能

### 动态图集系统 (Dynamic Atlas)
- 新增 DynamicAtlasManager:运行时纹理打包,支持 MaxRects 算法
- 新增 DynamicAtlasService:自动纹理加载与图集管理
- 新增 BinPacker:高效矩形打包算法
- 支持动态/固定两种扩展策略
- 自动 UV 重映射,实现 UI 元素合批渲染

### Frame Debugger 增强
- 新增合批分析面板,显示批次中断原因
- 新增 UI 元素层级信息(depth, worldOrderInLayer)
- 新增实体高亮功能,点击可在场景中定位
- 新增动态图集可视化面板
- 改进渲染原语详情展示

### 闪光效果 (Shiny Effect)
- 新增 UIShinyEffectComponent:UI 闪光参数配置
- 新增 UIShinyEffectSystem:材质覆盖驱动的闪光动画
- 新增 ShinyEffectComponent/System(Sprite 版本)

## 引擎层改进

### Rust 纹理管理扩展
- create_blank_texture:创建空白 GPU 纹理
- update_texture_region:局部纹理更新
- 支持动态图集的 GPU 端操作

### 材质系统
- 新增 effects/ 目录:ShinyEffect 等效果实现
- 新增 interfaces/ 目录:IMaterial 等接口定义
- 新增 mixins/ 目录:可组合的材质功能

### EngineBridge 扩展
- 新增 createBlankTexture/updateTextureRegion 方法
- 改进纹理加载回调机制

## UI 渲染改进
- UIRenderCollector:支持合批调试信息
- 稳定排序:addIndex 保证渲染顺序一致性
- 九宫格渲染优化
- 材质覆盖支持

## 其他改进
- 国际化:新增 Frame Debugger 相关翻译
- 编辑器:新增渲染调试入口
- 文档:新增架构设计文档目录

* refactor(ui): 引入新基础组件架构与渲染工具函数

Phase 1 重构 - 组件职责分离与代码复用:

新增基础组件层:
- UIGraphicComponent: 所有可视 UI 元素的基类(颜色、透明度、raycast)
- UIImageComponent: 纹理显示组件(支持简单、切片、平铺、填充模式)
- UISelectableComponent: 可交互元素的基类(状态管理、颜色过渡)

新增渲染工具:
- UIRenderUtils: 提取共享的坐标计算、边框渲染、阴影渲染等工具函数
- getUIRenderTransform: 统一的变换数据提取
- renderBorder/renderShadow: 复用的边框和阴影渲染逻辑

新增渲染系统:
- UIGraphicRenderSystem: 处理新基础组件的统一渲染器

重构现有系统:
- UIRectRenderSystem: 使用新工具函数,移除重复代码
- UIButtonRenderSystem: 使用新工具函数,移除重复代码

这些改动为后续统一渲染系统奠定基础。

* refactor(ui): UIProgressBarRenderSystem 使用渲染工具函数

- 使用 getUIRenderTransform 替代手动变换计算
- 使用 renderBorder 工具函数替代重复的边框渲染
- 使用 lerpColor 工具函数替代重复的颜色插值
- 简化方法签名,使用 UIRenderTransform 类型
- 移除约 135 行重复代码

* refactor(ui): Slider 和 ScrollView 渲染系统使用工具函数

- UISliderRenderSystem: 使用 getUIRenderTransform,简化方法签名
- UIScrollViewRenderSystem: 使用 getUIRenderTransform,简化方法签名
- 统一使用 UIRenderTransform 类型减少参数传递
- 消除重复的变换计算代码

* refactor(ui): 使用 UIWidgetMarker 消除硬编码组件依赖

- 新增 UIWidgetMarker 标记组件
- UIRectRenderSystem 改为检查标记而非硬编码4种组件类型
- 各 Widget 渲染系统自动添加标记组件
- 减少模块间耦合,提高可扩展性

* feat(ui): 实现 Canvas 隔离机制

- 新增 UICanvasComponent 定义 Canvas 渲染组
- UITransformComponent 添加 Canvas 相关字段:canvasEntityId, worldSortingLayer, pixelPerfect
- UILayoutSystem 传播 Canvas 设置给子元素
- UIRenderUtils 使用 Canvas 继承的排序层
- 支持嵌套 Canvas 和不同渲染模式

* refactor(ui): 统一纹理管理工具函数

Phase 4: 纹理管理统一

新增:
- UITextureUtils.ts: 统一的纹理描述符接口和验证函数
  - UITextureDescriptor: 支持 GUID/textureId/path 多种纹理源
  - isValidTextureGuid: GUID 验证
  - getTextureKey: 获取用于合批的纹理键
  - normalizeTextureDescriptor: 规范化各种输入格式
- utils/index.ts: 工具函数导出

修改:
- UIGraphicRenderSystem: 使用新的纹理工具函数
- index.ts: 导出纹理工具类型和函数

* refactor(ui): 实现统一的脏标记机制

Phase 5: Dirty 标记机制

新增:
- UIDirtyFlags.ts: 位标记枚举和追踪工具
  - UIDirtyFlags: Visual/Layout/Transform/Material/Text 标记
  - IDirtyTrackable: 脏追踪接口
  - DirtyTracker: 辅助工具类
  - 帧级别脏状态追踪 (markFrameDirty, isFrameDirty)

修改:
- UIGraphicComponent: 实现 IDirtyTrackable
  - 属性 setter 自动设置脏标记
  - 保留 setDirty/clearDirty 向后兼容
- UIImageComponent: 所有属性支持脏追踪
  - textureGuid/imageType/fillAmount 等变化自动标记
- UIGraphicRenderSystem: 使用 clearDirtyFlags()

导出:
- UIDirtyFlags, IDirtyTrackable, DirtyTracker
- markFrameDirty, isFrameDirty, clearFrameDirty

* refactor(ui): 移除过时的 dirty flag API

移除 UIGraphicComponent 中的兼容性 API:
- 移除 _isDirty getter/setter
- 移除 setDirty() 方法
- 移除 clearDirty() 方法

现在统一使用新的 dirty flag 系统:
- isDirty() / hasDirtyFlag(flags)
- markDirty(flags) / clearDirtyFlags()

* fix(ui): 修复两个 TODO 功能

1. 滑块手柄命中测试 (UIInputSystem)
   - UISliderComponent 添加 getHandleBounds() 计算手柄边界
   - UISliderComponent 添加 isPointInHandle() 精确命中测试
   - UIInputSystem.handleSlider() 使用精确测试更新悬停状态

2. 径向填充渲染 (UIGraphicRenderSystem)
   - 实现 renderRadialFill() 方法
   - 支持 radial90/radial180/radial360 三种模式
   - 支持 fillOrigin (top/right/bottom/left) 和 fillClockwise
   - 使用多段矩形近似饼形填充效果

* feat(ui): 完善 UI 系统架构和九宫格渲染

* fix(ui): 修复文本渲染层级问题并清理调试代码

- 修复纹理就绪后调用 invalidateUIRenderCaches() 导致的无限循环
- 移除 UITextRenderSystem、UIButtonRenderSystem、UIRectRenderSystem 中的首帧调试输出
- 移除 UILayoutSystem 中的布局调试日志
- 清理所有 __UI_RENDER_DEBUG__ 条件日志

* refactor(ui): 优化渲染批处理和输入框组件

渲染系统:
- 修复 RenderBatcher 保持渲染顺序
- 优化 Rust SpriteBatch 避免合并非连续精灵
- 增强 EngineRenderSystem 纹理就绪检测

输入框组件:
- 增强 UIInputFieldComponent 功能
- 改进 UIInputSystem 输入处理
- 新增 TextMeasureService 文本测量服务

* fix(ui): 修复九宫格首帧渲染和InputField输入问题

- 修复九宫格首帧 size=0x0 问题:
  - Viewport.tsx: 预览模式读取图片尺寸存储到 importSettings
  - AssetDatabase: ISpriteSettings 添加 width/height 字段
  - AssetMetadataService: getTextureSpriteInfo 使用元数据尺寸作为后备
  - UIRectRenderSystem: 当 atlasEntry 不存在时使用 spriteInfo 尺寸
  - WebBuildPipeline: 构建时包含 importSettings
  - AssetManager: 从 catalog 初始化时复制 importSettings
  - AssetTypes: IAssetCatalogEntry 添加 importSettings 字段

- 修复 InputField 无法输入问题:
  - UIRuntimeModule: manifest 添加 pluginExport: 'UIPlugin'
  - 确保预览模式正确加载 UI 插件并绑定 UIInputSystem

- 添加调试日志用于排查纹理加载问题

* fix(sprite): 修复类型导出错误

MaterialPropertyOverride 和 MaterialOverrides 应从 @esengine/material-system 导出

* fix(ui-editor): 补充 AnchorPreset 拉伸预设的映射

添加 StretchTop, StretchMiddle, StretchBottom, StretchLeft, StretchCenter, StretchRight 的位置和锚点值映射
This commit is contained in:
YHH
2025-12-19 15:33:36 +08:00
committed by GitHub
parent 958933cd76
commit 536c4c5593
145 changed files with 18187 additions and 1543 deletions

View File

@@ -384,17 +384,33 @@ export class EngineBridge implements ITextureEngineBridge {
}
/**
* Get texture information.
* 获取纹理信息。
* Get texture info by path.
* 通过路径获取纹理信息。
*
* @param id - Texture ID | 纹理ID
* This is the primary API for getting texture dimensions.
* The Rust engine is the single source of truth for texture dimensions.
* 这是获取纹理尺寸的主要 API。
* Rust 引擎是纹理尺寸的唯一事实来源。
*
* @param path - Image path/URL | 图片路径/URL
* @returns Texture info or null if not loaded | 纹理信息或未加载则为 null
*/
getTextureInfo(id: number): { width: number; height: number } | null {
getTextureInfoByPath(path: string): { width: number; height: number } | null {
if (!this.initialized) return null;
// TODO: Implement in Rust engine
// TODO: 在Rust引擎中实现
// Return default values for now / 暂时返回默认值
return { width: 64, height: 64 };
// Resolve path if resolver is set
// 如果设置了解析器,则解析路径
const resolvedPath = this.pathResolver ? this.pathResolver(path) : path;
// Query Rust engine for texture size
// 向 Rust 引擎查询纹理尺寸
const result = this.getEngine().getTextureSizeByPath(resolvedPath);
if (!result) return null;
return {
width: result[0],
height: result[1]
};
}
/**
@@ -1010,6 +1026,302 @@ export class EngineBridge implements ITextureEngineBridge {
});
}
// ===== Shader API =====
// ===== 着色器 API =====
/**
* Compile and register a custom shader program.
* 编译并注册自定义着色器程序。
*
* @param vertexSource - Vertex shader GLSL source | 顶点着色器 GLSL 源代码
* @param fragmentSource - Fragment shader GLSL source | 片段着色器 GLSL 源代码
* @returns Promise resolving to shader ID | 解析为着色器 ID 的 Promise
*/
async compileShader(vertexSource: string, fragmentSource: string): Promise<number> {
if (!this.initialized) throw new Error('Engine not initialized');
return this.getEngine().compileShader(vertexSource, fragmentSource);
}
/**
* Compile and register a shader with a specific ID.
* 使用特定 ID 编译并注册着色器。
*
* @param shaderId - Desired shader ID | 期望的着色器 ID
* @param vertexSource - Vertex shader GLSL source | 顶点着色器 GLSL 源代码
* @param fragmentSource - Fragment shader GLSL source | 片段着色器 GLSL 源代码
*/
async compileShaderWithId(shaderId: number, vertexSource: string, fragmentSource: string): Promise<void> {
if (!this.initialized) throw new Error('Engine not initialized');
this.getEngine().compileShaderWithId(shaderId, vertexSource, fragmentSource);
}
/**
* Check if a shader exists.
* 检查着色器是否存在。
*
* @param shaderId - Shader ID to check | 要检查的着色器 ID
*/
hasShader(shaderId: number): boolean {
if (!this.initialized) return false;
return this.getEngine().hasShader(shaderId);
}
/**
* Remove a shader.
* 移除着色器。
*
* @param shaderId - Shader ID to remove | 要移除的着色器 ID
*/
removeShader(shaderId: number): boolean {
if (!this.initialized) return false;
return this.getEngine().removeShader(shaderId);
}
// ===== Material Management API =====
// ===== 材质管理 API =====
/**
* Create a new material.
* 创建新材质。
*
* @param name - Material name | 材质名称
* @param shaderId - Shader ID to use | 使用的着色器 ID
* @param blendMode - Blend mode | 混合模式
* @returns Material ID | 材质 ID
*/
createMaterial(name: string, shaderId: number, blendMode: number): number {
if (!this.initialized) return -1;
return this.getEngine().createMaterial(name, shaderId, blendMode);
}
/**
* Create a material with a specific ID.
* 使用特定 ID 创建材质。
*
* @param materialId - Desired material ID | 期望的材质 ID
* @param name - Material name | 材质名称
* @param shaderId - Shader ID to use | 使用的着色器 ID
* @param blendMode - Blend mode | 混合模式
*/
createMaterialWithId(materialId: number, name: string, shaderId: number, blendMode: number): void {
if (!this.initialized) return;
this.getEngine().createMaterialWithId(materialId, name, shaderId, blendMode);
}
/**
* Check if a material exists.
* 检查材质是否存在。
*
* @param materialId - Material ID to check | 要检查的材质 ID
*/
hasMaterial(materialId: number): boolean {
if (!this.initialized) return false;
return this.getEngine().hasMaterial(materialId);
}
/**
* Remove a material.
* 移除材质。
*
* @param materialId - Material ID to remove | 要移除的材质 ID
*/
removeMaterial(materialId: number): boolean {
if (!this.initialized) return false;
return this.getEngine().removeMaterial(materialId);
}
// ===== Material Uniform API =====
// ===== 材质 Uniform API =====
/**
* Set a float uniform on a material.
* 设置材质的浮点 uniform。
*
* @param materialId - Material ID | 材质 ID
* @param name - Uniform name | Uniform 名称
* @param value - Float value | 浮点值
* @returns Whether the operation succeeded | 操作是否成功
*/
setMaterialFloat(materialId: number, name: string, value: number): boolean {
if (!this.initialized) return false;
return this.getEngine().setMaterialFloat(materialId, name, value);
}
/**
* Set a vec2 uniform on a material.
* 设置材质的 vec2 uniform。
*
* @param materialId - Material ID | 材质 ID
* @param name - Uniform name | Uniform 名称
* @param x - X component | X 分量
* @param y - Y component | Y 分量
* @returns Whether the operation succeeded | 操作是否成功
*/
setMaterialVec2(materialId: number, name: string, x: number, y: number): boolean {
if (!this.initialized) return false;
return this.getEngine().setMaterialVec2(materialId, name, x, y);
}
/**
* Set a vec3 uniform on a material.
* 设置材质的 vec3 uniform。
*
* @param materialId - Material ID | 材质 ID
* @param name - Uniform name | Uniform 名称
* @param x - X component | X 分量
* @param y - Y component | Y 分量
* @param z - Z component | Z 分量
* @returns Whether the operation succeeded | 操作是否成功
*/
setMaterialVec3(materialId: number, name: string, x: number, y: number, z: number): boolean {
if (!this.initialized) return false;
return this.getEngine().setMaterialVec3(materialId, name, x, y, z);
}
/**
* Set a vec4 uniform on a material.
* 设置材质的 vec4 uniform。
*
* @param materialId - Material ID | 材质 ID
* @param name - Uniform name | Uniform 名称
* @param x - X component | X 分量
* @param y - Y component | Y 分量
* @param z - Z component | Z 分量
* @param w - W component | W 分量
* @returns Whether the operation succeeded | 操作是否成功
*/
setMaterialVec4(materialId: number, name: string, x: number, y: number, z: number, w: number): boolean {
if (!this.initialized) return false;
return this.getEngine().setMaterialVec4(materialId, name, x, y, z, w);
}
/**
* Set a color uniform on a material.
* 设置材质的颜色 uniform。
*
* @param materialId - Material ID | 材质 ID
* @param name - Uniform name | Uniform 名称
* @param r - Red component (0-1) | 红色分量 (0-1)
* @param g - Green component (0-1) | 绿色分量 (0-1)
* @param b - Blue component (0-1) | 蓝色分量 (0-1)
* @param a - Alpha component (0-1) | Alpha 分量 (0-1)
* @returns Whether the operation succeeded | 操作是否成功
*/
setMaterialColor(materialId: number, name: string, r: number, g: number, b: number, a: number): boolean {
if (!this.initialized) return false;
return this.getEngine().setMaterialColor(materialId, name, r, g, b, a);
}
/**
* Set a material's blend mode.
* 设置材质的混合模式。
*
* @param materialId - Material ID | 材质 ID
* @param blendMode - Blend mode (0=None, 1=Alpha, 2=Additive, 3=Multiply, 4=Screen, 5=PremultipliedAlpha)
* 混合模式 (0=无, 1=Alpha, 2=叠加, 3=正片叠底, 4=滤色, 5=预乘Alpha)
* @returns Whether the operation succeeded | 操作是否成功
*/
setMaterialBlendMode(materialId: number, blendMode: number): boolean {
if (!this.initialized) return false;
return this.getEngine().setMaterialBlendMode(materialId, blendMode);
}
// ===== Dynamic Atlas API =====
// ===== 动态图集 API =====
/**
* Create a blank texture for dynamic atlas.
* 为动态图集创建空白纹理。
*
* This creates a texture that can be filled later using `updateTextureRegion`.
* Used for runtime atlas generation to batch UI elements with different textures.
* 创建一个可以稍后使用 `updateTextureRegion` 填充的纹理。
* 用于运行时图集生成,以批处理使用不同纹理的 UI 元素。
*
* @param width - Texture width in pixels (recommended: 2048) | 纹理宽度推荐2048
* @param height - Texture height in pixels (recommended: 2048) | 纹理高度推荐2048
* @returns Texture ID for the created blank texture | 创建的空白纹理ID
*/
createBlankTexture(width: number, height: number): number {
if (!this.initialized) return -1;
return this.getEngine().createBlankTexture(width, height);
}
/**
* Update a region of an existing texture with pixel data.
* 使用像素数据更新现有纹理的区域。
*
* This is used for dynamic atlas to copy individual textures into the atlas.
* 用于动态图集将单个纹理复制到图集纹理中。
*
* @param id - The texture ID to update | 要更新的纹理ID
* @param x - X offset in the texture | 纹理中的X偏移
* @param y - Y offset in the texture | 纹理中的Y偏移
* @param width - Width of the region to update | 要更新的区域宽度
* @param height - Height of the region to update | 要更新的区域高度
* @param pixels - RGBA pixel data (Uint8Array, 4 bytes per pixel) | RGBA像素数据每像素4字节
*/
updateTextureRegion(
id: number,
x: number,
y: number,
width: number,
height: number,
pixels: Uint8Array
): void {
if (!this.initialized) return;
this.getEngine().updateTextureRegion(id, x, y, width, height, pixels);
}
/**
* Apply material overrides to a material.
* 将材质覆盖应用到材质。
*
* @param materialId - Material ID | 材质 ID
* @param overrides - Material property overrides | 材质属性覆盖
*/
applyMaterialOverrides(materialId: number, overrides: Record<string, { type: string; value: number | number[] }>): void {
if (!this.initialized || !overrides) return;
for (const [name, override] of Object.entries(overrides)) {
const { type, value } = override;
switch (type) {
case 'float':
this.setMaterialFloat(materialId, name, value as number);
break;
case 'vec2':
{
const v = value as number[];
this.setMaterialVec2(materialId, name, v[0], v[1]);
}
break;
case 'vec3':
{
const v = value as number[];
this.setMaterialVec3(materialId, name, v[0], v[1], v[2]);
}
break;
case 'vec4':
{
const v = value as number[];
this.setMaterialVec4(materialId, name, v[0], v[1], v[2], v[3]);
}
break;
case 'color':
{
const v = value as number[];
this.setMaterialColor(materialId, name, v[0], v[1], v[2], v[3] ?? 1.0);
}
break;
case 'int':
// Int is passed as float | Int 作为 float 传递
this.setMaterialFloat(materialId, name, value as number);
break;
}
}
}
/**
* Dispose the bridge and release resources.
* 销毁桥接并释放资源。

View File

@@ -35,17 +35,16 @@ import type { SpriteRenderData } from '../types';
*/
export class RenderBatcher {
private sprites: SpriteRenderData[] = [];
private sortByZ = false;
/**
* Create a new render batcher.
* 创建新的渲染批处理器。
*
* @param sortByZ - Whether to sort sprites by Z order | 是否按Z顺序排序精灵
* Sprites are stored in insertion order. The caller is responsible
* for adding sprites in the correct render order (back-to-front for 2D).
* 精灵按插入顺序存储。调用者负责以正确的渲染顺序添加精灵2D 中从后到前)。
*/
constructor(sortByZ = false) {
this.sortByZ = sortByZ;
}
constructor() {}
/**
* Add a sprite to the batch.
@@ -71,18 +70,20 @@ export class RenderBatcher {
* Get all sprites in the batch.
* 获取批处理中的所有精灵。
*
* @returns Sorted array of sprites | 排序后的精灵数组
* Sprites are returned in insertion order to preserve z-ordering.
* The rendering system is responsible for sorting sprites before adding them.
* 精灵按插入顺序返回以保持 z 顺序。
* 渲染系统负责在添加精灵前对其进行排序。
*
* @returns Array of sprites in insertion order | 按插入顺序排列的精灵数组
*/
getSprites(): SpriteRenderData[] {
// Sort by material ID first, then texture ID for better batching
// 先按材质ID排序再按纹理ID排序以获得更好的批处理效果
if (!this.sortByZ) {
this.sprites.sort((a, b) => {
const materialDiff = (a.materialId || 0) - (b.materialId || 0);
if (materialDiff !== 0) return materialDiff;
return a.textureId - b.textureId;
});
}
// NOTE: Previously sorted by materialId/textureId for batching optimization,
// but this broke z-ordering for UI elements where render order is critical.
// Sprites should be added in the correct render order by the caller.
// 注意:之前按 materialId/textureId 排序以优化批处理,
// 但这破坏了 UI 元素的 z 排序,而 UI 的渲染顺序至关重要。
// 调用者应该以正确的渲染顺序添加精灵。
return this.sprites;
}

View File

@@ -12,7 +12,7 @@ import { SpriteComponent } from '@esengine/sprite';
import type { EngineBridge } from '../core/EngineBridge';
import { RenderBatcher } from '../core/RenderBatcher';
import type { ITransformComponent } from '../core/SpriteRenderHelper';
import type { SpriteRenderData } from '../types';
import type { SpriteRenderData, MaterialOverrides } from '../types';
/**
* Render data from a provider
@@ -47,6 +47,10 @@ export interface ProviderRenderData {
* Overrides sortingLayer's bScreenSpace setting, for particles that need dynamic render space.
*/
bScreenSpace?: boolean;
/** Material IDs for each primitive. | 每个原语的材质 ID。 */
materialIds?: Uint32Array;
/** Material overrides (per-group). | 材质覆盖(按组)。 */
materialOverrides?: MaterialOverrides;
}
/**
@@ -132,6 +136,24 @@ export type GizmoDataProviderFn = (
*/
export type HasGizmoProviderFn = (component: Component) => boolean;
/**
* Function type for getting highlight color for gizmo.
* Used to inject GizmoInteractionService functionality from editor layer.
* 获取 gizmo 高亮颜色的函数类型。
* 用于从编辑器层注入 GizmoInteractionService 功能。
*/
export type GizmoHighlightColorFn = (
entityId: number,
baseColor: GizmoColorInternal,
isSelected: boolean
) => GizmoColorInternal;
/**
* Function type for getting hovered entity ID.
* 获取悬停实体 ID 的函数类型。
*/
export type GetHoveredEntityIdFn = () => number | null;
/**
* Type for transform component constructor.
* 变换组件构造函数类型。
@@ -198,6 +220,11 @@ export class EngineRenderSystem extends EntitySystem {
private gizmoDataProvider: GizmoDataProviderFn | null = null;
private hasGizmoProvider: HasGizmoProviderFn | null = null;
// Gizmo interaction functions (injected from editor layer)
// Gizmo 交互函数(从编辑器层注入)
private gizmoHighlightColorFn: GizmoHighlightColorFn | null = null;
private getHoveredEntityIdFn: GetHoveredEntityIdFn | null = null;
// UI Canvas boundary settings
// UI 画布边界设置
private uiCanvasWidth: number = 0;
@@ -218,6 +245,18 @@ export class EngineRenderSystem extends EntitySystem {
// 为 false编辑器模式UI 在世界空间渲染,跟随编辑器相机
private previewMode: boolean = false;
// ===== Material Instance Management =====
// ===== 材质实例管理 =====
// Maps (baseMaterialId, overridesHash) → instanceMaterialId
// 映射 (基础材质ID, 覆盖哈希) → 实例材质ID
private materialInstanceMap: Map<string, number> = new Map();
// Next instance ID (starts at 10000 to avoid collision with built-in materials)
// 下一个实例 ID从 10000 开始以避免与内置材质冲突)
private nextMaterialInstanceId: number = 10000;
// Track instances used this frame for cleanup
// 跟踪本帧使用的实例以便清理
private usedInstancesThisFrame: Set<number> = new Set();
/**
* Create a new engine render system.
* 创建新的引擎渲染系统。
@@ -281,8 +320,10 @@ export class EngineRenderSystem extends EntitySystem {
// Collect all render items separated by render space
// 按渲染空间分离收集所有渲染项
const worldSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[] }> = [];
const screenSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[] }> = [];
// addIndex is used for stable sorting when sortKeys are equal
// addIndex 用于当 sortKey 相等时实现稳定排序
const worldSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[]; addIndex?: number }> = [];
const screenSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[]; addIndex?: number }> = [];
// Collect sprites from entities (all in world space)
// 收集实体的 sprites都在世界空间
@@ -296,6 +337,24 @@ export class EngineRenderSystem extends EntitySystem {
// 收集 UI 渲染数据
if (this.uiRenderDataProvider) {
const uiRenderData = this.uiRenderDataProvider.getRenderData();
// Use addIndex to preserve original order for stable sorting
// 使用 addIndex 保持原始顺序以实现稳定排序
let uiAddIndex = 0;
// DEBUG: 输出 UI 渲染数据
// DEBUG: Output UI render data
if ((globalThis as any).__UI_RENDER_DEBUG__) {
console.log('[EngineRenderSystem] UI render batches:', uiRenderData.map((data, i) => ({
index: i,
orderInLayer: data.orderInLayer,
sortingLayer: data.sortingLayer,
tileCount: data.tileCount,
sortKey: sortingLayerManager.getSortKey(data.sortingLayer, data.orderInLayer),
textureIds: Array.from(data.textureIds).slice(0, 3), // 只显示前3个 | Show first 3 only
textureGuid: data.textureGuid
})));
}
for (const data of uiRenderData) {
const uiSprites = this.convertProviderDataToSprites(data);
if (uiSprites.length > 0) {
@@ -303,9 +362,9 @@ export class EngineRenderSystem extends EntitySystem {
// UI always goes to screen space in preview mode, world space in editor mode
// UI 在预览模式下始终在屏幕空间,编辑器模式下在世界空间
if (this.previewMode) {
screenSpaceItems.push({ sortKey, sprites: uiSprites });
screenSpaceItems.push({ sortKey, sprites: uiSprites, addIndex: uiAddIndex++ });
} else {
worldSpaceItems.push({ sortKey, sprites: uiSprites });
worldSpaceItems.push({ sortKey, sprites: uiSprites, addIndex: uiAddIndex++ });
}
}
}
@@ -320,6 +379,10 @@ export class EngineRenderSystem extends EntitySystem {
if (this.previewMode && screenSpaceItems.length > 0) {
this.renderScreenSpacePass(screenSpaceItems);
}
// ===== Cleanup unused material instances =====
// ===== 清理未使用的材质实例 =====
this.cleanupUnusedMaterialInstances();
}
/**
@@ -465,11 +528,29 @@ export class EngineRenderSystem extends EntitySystem {
* 渲染世界空间内容。
*/
private renderWorldSpacePass(
worldSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[] }>
worldSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[]; addIndex?: number }>
): void {
// Sort by sortKey (lower values render first, appear behind)
// Use addIndex as secondary key for stable sorting when sortKeys are equal
// 按 sortKey 排序(值越小越先渲染,显示在后面)
worldSpaceItems.sort((a, b) => a.sortKey - b.sortKey);
// 当 sortKey 相等时使用 addIndex 作为次要排序键以实现稳定排序
worldSpaceItems.sort((a, b) => {
const diff = a.sortKey - b.sortKey;
if (diff !== 0) return diff;
return (a.addIndex ?? 0) - (b.addIndex ?? 0);
});
// DEBUG: 输出排序后的世界空间渲染项
// DEBUG: Output sorted world space items
if ((globalThis as any).__UI_RENDER_DEBUG__) {
console.log('[EngineRenderSystem] World items after sort:', worldSpaceItems.map((item, i) => ({
index: i,
sortKey: item.sortKey,
addIndex: item.addIndex,
spriteCount: item.sprites.length,
firstTextureId: item.sprites[0]?.textureId
})));
}
// Submit all sprites in sorted order
// 按排序顺序提交所有 sprites
@@ -481,6 +562,11 @@ export class EngineRenderSystem extends EntitySystem {
if (!this.batcher.isEmpty) {
const sprites = this.batcher.getSprites();
// Apply material overrides before rendering
// 在渲染前应用材质覆盖
this.applySpriteMaterialOverrides(sprites);
this.bridge.submitSprites(sprites);
}
@@ -512,11 +598,15 @@ export class EngineRenderSystem extends EntitySystem {
* 渲染屏幕空间内容UI、屏幕覆盖层、模态层
*/
private renderScreenSpacePass(
screenSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[] }>
screenSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[]; addIndex?: number }>
): void {
// Sort by sortKey
// 按 sortKey 排序
screenSpaceItems.sort((a, b) => a.sortKey - b.sortKey);
// Sort by sortKey, use addIndex for stable sorting when equal
// 按 sortKey 排序,当相等时使用 addIndex 实现稳定排序
screenSpaceItems.sort((a, b) => {
const diff = a.sortKey - b.sortKey;
if (diff !== 0) return diff;
return (a.addIndex ?? 0) - (b.addIndex ?? 0);
});
// Switch to screen space projection
// 切换到屏幕空间投影
@@ -539,6 +629,11 @@ export class EngineRenderSystem extends EntitySystem {
if (!this.batcher.isEmpty) {
const sprites = this.batcher.getSprites();
// Apply material overrides before rendering
// 在渲染前应用材质覆盖
this.applySpriteMaterialOverrides(sprites);
this.bridge.submitSprites(sprites);
// Render overlay (without clearing screen)
// 渲染叠加层(不清屏)
@@ -550,6 +645,147 @@ export class EngineRenderSystem extends EntitySystem {
this.bridge.popScreenSpaceMode();
}
/**
* Generate a hash key for material overrides.
* 为材质覆盖生成哈希键。
*
* @param overrides - Material overrides | 材质覆盖
* @returns Hash string | 哈希字符串
*/
private hashMaterialOverrides(overrides: MaterialOverrides): string {
// Sort keys for consistent hashing
// 排序键以保持一致的哈希
const sortedKeys = Object.keys(overrides).sort();
const parts: string[] = [];
for (const key of sortedKeys) {
const override = overrides[key];
if (override) {
const valueStr = Array.isArray(override.value)
? override.value.map(v => v.toFixed(4)).join(',')
: override.value.toFixed(4);
parts.push(`${key}:${valueStr}`);
}
}
return parts.join('|');
}
/**
* Get or create a material instance for a specific base material + overrides combination.
* 为特定的基础材质+覆盖组合获取或创建材质实例。
*
* This ensures each unique (baseMaterial, overrides) combination gets its own
* material instance, preventing shared material state issues.
* 这确保每个唯一的(基础材质,覆盖)组合都有自己的材质实例,
* 防止共享材质状态问题。
*
* @param baseMaterialId - Base material ID (e.g., 1 for Grayscale) | 基础材质ID
* @param overrides - Material property overrides | 材质属性覆盖
* @returns Instance material ID | 实例材质ID
*/
private getOrCreateMaterialInstance(baseMaterialId: number, overrides: MaterialOverrides): number {
const overridesHash = this.hashMaterialOverrides(overrides);
const instanceKey = `${baseMaterialId}:${overridesHash}`;
// Check if instance already exists
// 检查实例是否已存在
let instanceId = this.materialInstanceMap.get(instanceKey);
if (instanceId !== undefined) {
this.usedInstancesThisFrame.add(instanceId);
return instanceId;
}
// Create new instance
// 创建新实例
instanceId = this.nextMaterialInstanceId++;
this.materialInstanceMap.set(instanceKey, instanceId);
this.usedInstancesThisFrame.add(instanceId);
// Clone the base material with the new ID
// 使用新ID克隆基础材质
// For built-in materials, shaderId = materialId (1:1 mapping)
// 对于内置材质shaderId = materialId1:1 映射)
const shaderId = baseMaterialId;
const blendMode = 1; // Alpha blending
this.bridge.createMaterialWithId(instanceId, `Instance_${baseMaterialId}_${instanceId}`, shaderId, blendMode);
// Apply overrides to the new instance
// 将覆盖应用到新实例
this.bridge.applyMaterialOverrides(instanceId, overrides);
return instanceId;
}
/**
* Clean up unused material instances.
* 清理未使用的材质实例。
*
* Called at the end of each frame to remove instances that were not used.
* 在每帧结束时调用,移除未使用的实例。
*/
private cleanupUnusedMaterialInstances(): void {
const toRemove: string[] = [];
for (const [key, instanceId] of this.materialInstanceMap.entries()) {
if (!this.usedInstancesThisFrame.has(instanceId)) {
this.bridge.removeMaterial(instanceId);
toRemove.push(key);
}
}
for (const key of toRemove) {
this.materialInstanceMap.delete(key);
}
// Clear the used set for next frame
// 清除已用集合以便下一帧
this.usedInstancesThisFrame.clear();
}
/**
* Apply material overrides from sprites to the engine.
* 将 sprites 的材质覆盖应用到引擎。
*
* For sprites with overrides, this creates unique material instances
* to ensure each sprite's overrides don't affect other sprites.
* 对于有覆盖的精灵,这会创建唯一的材质实例,
* 确保每个精灵的覆盖不会影响其他精灵。
*/
private applySpriteMaterialOverrides(sprites: SpriteRenderData[]): void {
// Track which instance materials we've already applied overrides to this frame
// 跟踪本帧已应用覆盖的实例材质
const appliedInstances = new Set<number>();
for (const sprite of sprites) {
const baseMaterialId = sprite.materialId;
// Skip if no material or no overrides
// 如果没有材质或没有覆盖,跳过
if (!baseMaterialId || baseMaterialId <= 0 || !sprite.materialOverrides) {
continue;
}
const overrideKeys = Object.keys(sprite.materialOverrides);
if (overrideKeys.length === 0) {
continue;
}
// Get or create a unique material instance for this sprite's overrides
// 为此精灵的覆盖获取或创建唯一的材质实例
const instanceId = this.getOrCreateMaterialInstance(baseMaterialId, sprite.materialOverrides);
// Update the sprite to use the instance material
// 更新精灵以使用实例材质
sprite.materialId = instanceId;
// Apply overrides if not already done for this instance
// 如果尚未为此实例应用覆盖,则应用
if (!appliedInstances.has(instanceId)) {
this.bridge.applyMaterialOverrides(instanceId, sprite.materialOverrides);
appliedInstances.add(instanceId);
}
}
}
/**
* Convert provider render data to sprite render data array.
* 将提供者渲染数据转换为 Sprite 渲染数据数组。
@@ -562,6 +798,11 @@ export class EngineRenderSystem extends EntitySystem {
textureId = this.bridge.getOrLoadTextureByPath(this.resolveAssetPath(data.textureGuid));
}
// Check for material data
// 检查材质数据
const hasMaterialIds = data.materialIds && data.materialIds.length > 0;
const hasMaterialOverrides = data.materialOverrides && Object.keys(data.materialOverrides).length > 0;
const sprites: SpriteRenderData[] = [];
for (let i = 0; i < data.tileCount; i++) {
const tOffset = i * 7;
@@ -587,6 +828,15 @@ export class EngineRenderSystem extends EntitySystem {
color: data.colors[i]
};
// Add material data if present
// 如果存在材质数据,添加它
if (hasMaterialIds) {
renderData.materialId = data.materialIds![i];
}
if (hasMaterialOverrides) {
renderData.materialOverrides = data.materialOverrides;
}
sprites.push(renderData);
}
@@ -601,10 +851,15 @@ export class EngineRenderSystem extends EntitySystem {
const scene = Core.scene;
if (!scene || !this.gizmoDataProvider || !this.hasGizmoProvider) return;
// Get hovered entity ID for highlight
// 获取悬停的实体 ID 用于高亮
const hoveredEntityId = this.getHoveredEntityIdFn?.() ?? null;
// Iterate all entities in the scene
// 遍历场景中的所有实体
for (const entity of scene.entities.buffer) {
const isSelected = this.selectedEntityIds.has(entity.id);
const isHovered = entity.id === hoveredEntityId;
// Check each component for gizmo provider
// 检查每个组件是否有 gizmo 提供者
@@ -613,6 +868,15 @@ export class EngineRenderSystem extends EntitySystem {
try {
const gizmoDataArray = this.gizmoDataProvider(component, entity, isSelected);
for (const gizmoData of gizmoDataArray) {
// Apply hover highlight color if applicable
// 如果适用,应用悬停高亮颜色
if (isHovered && this.gizmoHighlightColorFn) {
gizmoData.color = this.gizmoHighlightColorFn(
entity.id,
gizmoData.color,
isSelected
);
}
this.renderGizmoData(gizmoData);
}
} catch (e) {
@@ -1037,6 +1301,26 @@ export class EngineRenderSystem extends EntitySystem {
this.hasGizmoProvider = hasProvider;
}
/**
* Set gizmo interaction functions.
* 设置 gizmo 交互函数。
*
* This allows the editor layer to inject GizmoInteractionService functionality
* for hover highlighting and click selection.
* 这允许编辑器层注入 GizmoInteractionService 功能,
* 用于悬停高亮和点击选择。
*
* @param highlightColorFn - Function to get highlight color for gizmo
* @param getHoveredEntityIdFn - Function to get currently hovered entity ID
*/
setGizmoInteraction(
highlightColorFn: GizmoHighlightColorFn,
getHoveredEntityIdFn: GetHoveredEntityIdFn
): void {
this.gizmoHighlightColorFn = highlightColorFn;
this.getHoveredEntityIdFn = getHoveredEntityIdFn;
}
/**
* Set gizmo visibility.
* 设置Gizmo可见性。

View File

@@ -338,6 +338,23 @@ export class GameEngine {
* 注销视口。
*/
unregisterViewport(id: string): void;
/**
* Create a blank texture for dynamic atlas.
* 为动态图集创建空白纹理。
*
* This creates a texture that can be filled later using `updateTextureRegion`.
* Used for runtime atlas generation to batch UI elements with different textures.
* 创建一个可以稍后使用 `updateTextureRegion` 填充的纹理。
* 用于运行时图集生成,以批处理使用不同纹理的 UI 元素。
*
* # Arguments | 参数
* * `width` - Texture width in pixels (recommended: 2048) | 纹理宽度推荐2048
* * `height` - Texture height in pixels (recommended: 2048) | 纹理高度推荐2048
*
* # Returns | 返回
* The texture ID for the created blank texture | 创建的空白纹理ID
*/
createBlankTexture(width: number, height: number): number;
/**
* Load texture by path, returning texture ID.
* 按路径加载纹理返回纹理ID。
@@ -346,6 +363,22 @@ export class GameEngine {
* * `path` - Image path/URL to load | 要加载的图片路径/URL
*/
loadTextureByPath(path: string): number;
/**
* Update a region of an existing texture with pixel data.
* 使用像素数据更新现有纹理的区域。
*
* This is used for dynamic atlas to copy individual textures into the atlas.
* 用于动态图集将单个纹理复制到图集纹理中。
*
* # Arguments | 参数
* * `id` - The texture ID to update | 要更新的纹理ID
* * `x` - X offset in the texture | 纹理中的X偏移
* * `y` - Y offset in the texture | 纹理中的Y偏移
* * `width` - Width of the region to update | 要更新的区域宽度
* * `height` - Height of the region to update | 要更新的区域高度
* * `pixels` - RGBA pixel data (Uint8Array, 4 bytes per pixel) | RGBA像素数据每像素4字节
*/
updateTextureRegion(id: number, x: number, y: number, width: number, height: number, pixels: Uint8Array): void;
/**
* Compile a shader with a specific ID.
* 使用特定ID编译着色器。
@@ -381,6 +414,17 @@ export class GameEngine {
* 在恢复场景快照时应调用此方法以确保纹理使用正确的ID重新加载。
*/
clearTexturePathCache(): void;
/**
* Get texture size by path.
* 按路径获取纹理尺寸。
*
* Returns an array [width, height] or null if not found.
* 返回数组 [width, height],如果未找到则返回 null。
*
* # Arguments | 参数
* * `path` - Image path to lookup | 要查找的图片路径
*/
getTextureSizeByPath(path: string): Float32Array | undefined;
/**
* 获取正在加载中的纹理数量
* Get the number of textures currently loading
@@ -448,6 +492,7 @@ export interface InitOutput {
readonly gameengine_clearTexturePathCache: (a: number) => void;
readonly gameengine_compileShader: (a: number, b: number, c: number, d: number, e: number) => [number, number, number];
readonly gameengine_compileShaderWithId: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number];
readonly gameengine_createBlankTexture: (a: number, b: number, c: number) => [number, number, number];
readonly gameengine_createMaterial: (a: number, b: number, c: number, d: number, e: number) => number;
readonly gameengine_createMaterialWithId: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
readonly gameengine_fromExternal: (a: any, b: number, c: number) => [number, number, number];
@@ -455,6 +500,7 @@ export interface InitOutput {
readonly gameengine_getOrLoadTextureByPath: (a: number, b: number, c: number) => [number, number, number];
readonly gameengine_getTextureIdByPath: (a: number, b: number, c: number) => number;
readonly gameengine_getTextureLoadingCount: (a: number) => number;
readonly gameengine_getTextureSizeByPath: (a: number, b: number, c: number) => any;
readonly gameengine_getTextureState: (a: number, b: number) => [number, number];
readonly gameengine_getViewportCamera: (a: number, b: number, c: number) => [number, number];
readonly gameengine_getViewportIds: (a: number) => [number, number];
@@ -494,6 +540,7 @@ export interface InitOutput {
readonly gameengine_submitSpriteBatch: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number) => [number, number];
readonly gameengine_unregisterViewport: (a: number, b: number, c: number) => void;
readonly gameengine_updateInput: (a: number) => void;
readonly gameengine_updateTextureRegion: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => [number, number];
readonly gameengine_width: (a: number) => number;
readonly gameengine_worldToScreen: (a: number, b: number, c: number) => [number, number];
readonly init: () => void;