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

@@ -0,0 +1,663 @@
# ESEngine 材质系统统一架构重构方案
## 问题概述
当前 UI 和 Scene (Sprite) 两套渲染系统存在大量代码重复:
| 重复项 | Sprite | UI | 重复度 |
|--------|--------|----|----|
| 材质属性覆盖接口 | `MaterialPropertyOverride` | `UIMaterialPropertyOverride` | 100% |
| 材质方法 (12个) | `SpriteComponent` | `UIRenderComponent` | 100% |
| ShinyEffect 组件 | `ShinyEffectComponent` | `UIShinyEffectComponent` | 99% |
| ShinyEffect 系统 | `ShinyEffectSystem` | `UIShinyEffectSystem` | 98% |
**根本原因**:缺乏统一的材质覆盖接口抽象层。
---
## 一、统一材质覆盖接口
### 1.1 定义通用接口
`@esengine/material-system` 包中定义统一接口:
```typescript
// packages/material-system/src/interfaces/IMaterialOverridable.ts
/**
* Material property override definition.
* 材质属性覆盖定义。
*/
export interface MaterialPropertyOverride {
type: 'float' | 'vec2' | 'vec3' | 'vec4' | 'color' | 'int';
value: number | number[];
}
export type MaterialOverrides = Record<string, MaterialPropertyOverride>;
/**
* Interface for components that support material property overrides.
* 支持材质属性覆盖的组件接口。
*/
export interface IMaterialOverridable {
/** Material GUID for asset reference | 材质资产引用的 GUID */
materialGuid: string;
/** Current material overrides | 当前材质覆盖 */
readonly materialOverrides: MaterialOverrides;
/** Get current material ID | 获取当前材质 ID */
getMaterialId(): number;
/** Set material ID | 设置材质 ID */
setMaterialId(id: number): void;
// Uniform setters
setOverrideFloat(name: string, value: number): this;
setOverrideVec2(name: string, x: number, y: number): this;
setOverrideVec3(name: string, x: number, y: number, z: number): this;
setOverrideVec4(name: string, x: number, y: number, z: number, w: number): this;
setOverrideColor(name: string, r: number, g: number, b: number, a?: number): this;
setOverrideInt(name: string, value: number): this;
// Uniform getters
getOverride(name: string): MaterialPropertyOverride | undefined;
removeOverride(name: string): this;
clearOverrides(): this;
hasOverrides(): boolean;
}
```
### 1.2 创建 Mixin 实现
使用 Mixin 模式避免代码重复:
```typescript
// packages/material-system/src/mixins/MaterialOverridableMixin.ts
import type { MaterialPropertyOverride, MaterialOverrides } from '../interfaces/IMaterialOverridable';
/**
* Mixin that provides material override functionality.
* 提供材质覆盖功能的 Mixin。
*/
export function MaterialOverridableMixin<TBase extends new (...args: any[]) => {}>(Base: TBase) {
return class extends Base {
materialGuid: string = '';
private _materialId: number = 0;
private _materialOverrides: MaterialOverrides = {};
get materialOverrides(): MaterialOverrides {
return this._materialOverrides;
}
getMaterialId(): number {
return this._materialId;
}
setMaterialId(id: number): void {
this._materialId = id;
}
setOverrideFloat(name: string, value: number): this {
this._materialOverrides[name] = { type: 'float', value };
return this;
}
setOverrideVec2(name: string, x: number, y: number): this {
this._materialOverrides[name] = { type: 'vec2', value: [x, y] };
return this;
}
setOverrideVec3(name: string, x: number, y: number, z: number): this {
this._materialOverrides[name] = { type: 'vec3', value: [x, y, z] };
return this;
}
setOverrideVec4(name: string, x: number, y: number, z: number, w: number): this {
this._materialOverrides[name] = { type: 'vec4', value: [x, y, z, w] };
return this;
}
setOverrideColor(name: string, r: number, g: number, b: number, a: number = 1.0): this {
this._materialOverrides[name] = { type: 'color', value: [r, g, b, a] };
return this;
}
setOverrideInt(name: string, value: number): this {
this._materialOverrides[name] = { type: 'int', value: Math.floor(value) };
return this;
}
getOverride(name: string): MaterialPropertyOverride | undefined {
return this._materialOverrides[name];
}
removeOverride(name: string): this {
delete this._materialOverrides[name];
return this;
}
clearOverrides(): this {
this._materialOverrides = {};
return this;
}
hasOverrides(): boolean {
return Object.keys(this._materialOverrides).length > 0;
}
};
}
```
---
## 二、Shader Property 元数据系统
### 2.1 定义属性元数据接口
```typescript
// packages/material-system/src/interfaces/IShaderProperty.ts
/**
* Shader property UI metadata.
* 着色器属性 UI 元数据。
*/
export interface ShaderPropertyMeta {
/** Property type | 属性类型 */
type: 'float' | 'vec2' | 'vec3' | 'vec4' | 'color' | 'int' | 'texture';
/** Display label (supports i18n key) | 显示标签(支持 i18n 键) */
label: string;
/** Property group for organization | 属性分组 */
group?: string;
/** Default value | 默认值 */
default?: number | number[] | string;
// Numeric constraints
min?: number;
max?: number;
step?: number;
/** UI hints | UI 提示 */
hint?: 'range' | 'angle' | 'hdr' | 'normal';
/** Tooltip description | 工具提示描述 */
tooltip?: string;
/** Whether to hide in inspector | 是否在检查器中隐藏 */
hidden?: boolean;
}
/**
* Extended shader definition with property metadata.
* 带属性元数据的扩展着色器定义。
*/
export interface ShaderAssetDefinition {
/** Shader name | 着色器名称 */
name: string;
/** Display name for UI | UI 显示名称 */
displayName?: string;
/** Shader description | 着色器描述 */
description?: string;
/** Vertex shader source (inline or path) | 顶点着色器源(内联或路径)*/
vertexSource: string;
/** Fragment shader source (inline or path) | 片段着色器源(内联或路径)*/
fragmentSource: string;
/** Property metadata for inspector | 检查器属性元数据 */
properties?: Record<string, ShaderPropertyMeta>;
/** Render queue / order | 渲染队列/顺序 */
renderQueue?: number;
/** Preset blend mode | 预设混合模式 */
blendMode?: 'alpha' | 'additive' | 'multiply' | 'opaque';
}
```
### 2.2 .shader 资产文件格式
```json
{
"$schema": "esengine://schemas/shader.json",
"version": 1,
"name": "Shiny",
"displayName": "闪光效果 | Shiny Effect",
"description": "扫光高亮动画着色器 | Sweeping highlight animation shader",
"vertexSource": "./shaders/sprite.vert",
"fragmentSource": "./shaders/shiny.frag",
"blendMode": "alpha",
"renderQueue": 2000,
"properties": {
"u_shinyProgress": {
"type": "float",
"label": "进度 | Progress",
"group": "Animation",
"default": 0,
"min": 0,
"max": 1,
"step": 0.01,
"hidden": true
},
"u_shinyWidth": {
"type": "float",
"label": "宽度 | Width",
"group": "Effect",
"default": 0.25,
"min": 0,
"max": 1,
"step": 0.01,
"tooltip": "闪光带宽度 | Width of the shiny band"
},
"u_shinyRotation": {
"type": "float",
"label": "角度 | Rotation",
"group": "Effect",
"default": 2.25,
"min": 0,
"max": 6.28,
"step": 0.01,
"hint": "angle"
},
"u_shinySoftness": {
"type": "float",
"label": "柔和度 | Softness",
"group": "Effect",
"default": 1.0,
"min": 0,
"max": 1,
"step": 0.01
},
"u_shinyBrightness": {
"type": "float",
"label": "亮度 | Brightness",
"group": "Effect",
"default": 1.0,
"min": 0,
"max": 2,
"step": 0.01
},
"u_shinyGloss": {
"type": "float",
"label": "光泽度 | Gloss",
"group": "Effect",
"default": 1.0,
"min": 0,
"max": 1,
"step": 0.01,
"tooltip": "0=白色高光, 1=带颜色 | 0=white shine, 1=color-tinted"
}
}
}
```
---
## 三、统一效果组件/系统架构
### 3.1 抽取通用 ShinyEffect 基类
```typescript
// packages/material-system/src/effects/BaseShinyEffect.ts
import { Component, Property, Serializable, Serialize } from '@esengine/ecs-framework';
/**
* Base shiny effect configuration (shared between UI and Sprite).
* 基础闪光效果配置UI 和 Sprite 共享)。
*/
export abstract class BaseShinyEffect extends Component {
// ============= Effect Parameters =============
@Serialize()
@Property({ type: 'number', label: 'Width', min: 0, max: 1, step: 0.01 })
public width: number = 0.25;
@Serialize()
@Property({ type: 'number', label: 'Rotation', min: 0, max: 360, step: 1 })
public rotation: number = 129;
@Serialize()
@Property({ type: 'number', label: 'Softness', min: 0, max: 1, step: 0.01 })
public softness: number = 1.0;
@Serialize()
@Property({ type: 'number', label: 'Brightness', min: 0, max: 2, step: 0.01 })
public brightness: number = 1.0;
@Serialize()
@Property({ type: 'number', label: 'Gloss', min: 0, max: 2, step: 0.01 })
public gloss: number = 1.0;
// ============= Animation Settings =============
@Serialize()
@Property({ type: 'boolean', label: 'Play' })
public play: boolean = true;
@Serialize()
@Property({ type: 'boolean', label: 'Loop' })
public loop: boolean = true;
@Serialize()
@Property({ type: 'number', label: 'Duration', min: 0.1, step: 0.1 })
public duration: number = 2.0;
@Serialize()
@Property({ type: 'number', label: 'Loop Delay', min: 0, step: 0.1 })
public loopDelay: number = 2.0;
@Serialize()
@Property({ type: 'number', label: 'Initial Delay', min: 0, step: 0.1 })
public initialDelay: number = 0;
// ============= Runtime State =============
public progress: number = 0;
public elapsedTime: number = 0;
public inDelay: boolean = false;
public delayRemaining: number = 0;
public initialDelayProcessed: boolean = false;
reset(): void {
this.progress = 0;
this.elapsedTime = 0;
this.inDelay = false;
this.delayRemaining = 0;
this.initialDelayProcessed = false;
}
start(): void {
this.reset();
this.play = true;
}
stop(): void {
this.play = false;
}
getRotationRadians(): number {
return this.rotation * Math.PI / 180;
}
}
```
### 3.2 通用动画更新逻辑
```typescript
// packages/material-system/src/effects/ShinyEffectAnimator.ts
import type { BaseShinyEffect } from './BaseShinyEffect';
import type { IMaterialOverridable } from '../interfaces/IMaterialOverridable';
import { BuiltInShaders } from '../types';
/**
* Shared animator logic for shiny effect.
* 闪光效果共享的动画逻辑。
*/
export class ShinyEffectAnimator {
/**
* Update animation state.
* 更新动画状态。
*/
static updateAnimation(shiny: BaseShinyEffect, deltaTime: number): void {
if (!shiny.initialDelayProcessed && shiny.initialDelay > 0) {
shiny.delayRemaining = shiny.initialDelay;
shiny.inDelay = true;
shiny.initialDelayProcessed = true;
}
if (shiny.inDelay) {
shiny.delayRemaining -= deltaTime;
if (shiny.delayRemaining <= 0) {
shiny.inDelay = false;
shiny.elapsedTime = 0;
}
return;
}
shiny.elapsedTime += deltaTime;
shiny.progress = Math.min(shiny.elapsedTime / shiny.duration, 1.0);
if (shiny.progress >= 1.0) {
if (shiny.loop) {
shiny.inDelay = true;
shiny.delayRemaining = shiny.loopDelay;
shiny.progress = 0;
shiny.elapsedTime = 0;
} else {
shiny.play = false;
shiny.progress = 1.0;
}
}
}
/**
* Apply material overrides.
* 应用材质覆盖。
*/
static applyMaterialOverrides(shiny: BaseShinyEffect, target: IMaterialOverridable): void {
if (target.getMaterialId() === 0) {
target.setMaterialId(BuiltInShaders.Shiny);
}
target.setOverrideFloat('u_shinyProgress', shiny.progress);
target.setOverrideFloat('u_shinyWidth', shiny.width);
target.setOverrideFloat('u_shinyRotation', shiny.getRotationRadians());
target.setOverrideFloat('u_shinySoftness', shiny.softness);
target.setOverrideFloat('u_shinyBrightness', shiny.brightness);
target.setOverrideFloat('u_shinyGloss', shiny.gloss);
}
}
```
---
## 四、Material Inspector 设计
### 4.1 组件架构
```
MaterialPropertiesEditor (容器组件)
├── ShaderSelector (着色器选择器)
├── PropertyGroup (属性分组)
│ ├── FloatProperty (浮点属性)
│ ├── VectorProperty (向量属性)
│ ├── ColorProperty (颜色属性)
│ └── TextureProperty (纹理属性)
└── OverrideIndicator (覆盖指示器)
```
### 4.2 核心组件
```typescript
// packages/editor-app/src/components/inspectors/material/MaterialPropertiesEditor.tsx
interface MaterialPropertiesEditorProps {
/** Target component implementing IMaterialOverridable */
target: IMaterialOverridable;
/** Current shader definition with property metadata */
shaderDef?: ShaderAssetDefinition;
/** Callback when property changes */
onChange?: (name: string, value: MaterialPropertyOverride) => void;
}
export const MaterialPropertiesEditor: React.FC<MaterialPropertiesEditorProps> = ({
target,
shaderDef,
onChange
}) => {
// Group properties by their group field
const groupedProps = useMemo(() => {
if (!shaderDef?.properties) return {};
const groups: Record<string, Array<[string, ShaderPropertyMeta]>> = {};
for (const [name, meta] of Object.entries(shaderDef.properties)) {
if (meta.hidden) continue;
const group = meta.group || 'Default';
if (!groups[group]) groups[group] = [];
groups[group].push([name, meta]);
}
return groups;
}, [shaderDef]);
return (
<div className="material-properties-editor">
<ShaderSelector
currentShaderId={target.getMaterialId()}
onSelect={(id) => target.setMaterialId(id)}
/>
{Object.entries(groupedProps).map(([group, props]) => (
<PropertyGroup key={group} title={group}>
{props.map(([name, meta]) => (
<PropertyField
key={name}
name={name}
meta={meta}
value={target.getOverride(name)?.value ?? meta.default}
onChange={(value) => {
applyOverride(target, name, meta.type, value);
onChange?.(name, target.getOverride(name)!);
}}
/>
))}
</PropertyGroup>
))}
</div>
);
};
```
---
## 五、实施计划
### Phase 1: 接口层 (1-2 天)
1. **创建 IMaterialOverridable 接口** (`packages/material-system/src/interfaces/`)
2. **创建 MaterialOverridableMixin** (`packages/material-system/src/mixins/`)
3. **导出新接口** (`packages/material-system/src/index.ts`)
### Phase 2: 重构现有组件 (2-3 天)
1. **修改 SpriteComponent**:实现 `IMaterialOverridable`,使用 Mixin
2. **修改 UIRenderComponent**:实现 `IMaterialOverridable`,使用 Mixin
3. **删除重复代码**:移除各组件中的重复材质方法
### Phase 3: 统一效果系统 (2-3 天)
1. **创建 BaseShinyEffect** (`packages/material-system/src/effects/`)
2. **创建 ShinyEffectAnimator** (`packages/material-system/src/effects/`)
3. **重构 ShinyEffectComponent**:继承 BaseShinyEffect
4. **重构 UIShinyEffectComponent**:继承 BaseShinyEffect
5. **重构系统**:使用 ShinyEffectAnimator
### Phase 4: Shader Property 系统 (2-3 天)
1. **定义 ShaderPropertyMeta 接口**
2. **扩展 ShaderDefinition** 添加 properties 字段
3. **创建 ShaderLoader** 支持 .shader 文件
4. **注册内置着色器属性元数据**
### Phase 5: Material Inspector (3-4 天)
1. **创建 MaterialPropertiesEditor 组件**
2. **创建 PropertyField 组件** (Float, Vector, Color, Texture)
3. **集成到现有 Inspector 系统**
4. **支持实时预览**
---
## 六、文件修改清单
| 优先级 | 包 | 文件 | 操作 |
|--------|-----|------|------|
| P0 | material-system | `src/interfaces/IMaterialOverridable.ts` | 新建 |
| P0 | material-system | `src/mixins/MaterialOverridableMixin.ts` | 新建 |
| P0 | material-system | `src/interfaces/IShaderProperty.ts` | 新建 |
| P1 | material-system | `src/effects/BaseShinyEffect.ts` | 新建 |
| P1 | material-system | `src/effects/ShinyEffectAnimator.ts` | 新建 |
| P1 | sprite | `src/SpriteComponent.ts` | 重构 |
| P1 | ui | `src/components/UIRenderComponent.ts` | 重构 |
| P2 | sprite | `src/ShinyEffectComponent.ts` | 重构 |
| P2 | ui | `src/components/UIShinyEffectComponent.ts` | 重构 |
| P2 | sprite | `src/systems/ShinyEffectSystem.ts` | 重构 |
| P2 | ui | `src/systems/render/UIShinyEffectSystem.ts` | 重构 |
| P3 | material-system | `src/loaders/ShaderLoader.ts` | 扩展 |
| P3 | editor-app | `src/components/inspectors/material/*` | 新建 |
---
## 七、Transform 组件统一(可选)
### 7.1 现状分析
| 特性 | TransformComponent | UITransformComponent |
|------|-------------------|---------------------|
| **坐标系** | 绝对坐标 (position.x/y/z) | 相对锚点坐标 (x/y + anchor) |
| **尺寸** | ❌ 无 | ✅ width/height + 约束 |
| **锚点系统** | ❌ 无 | ✅ anchorMin/Max |
| **3D 支持** | ✅ IVector3 | ❌ 纯 2D |
| **可见性** | ❌ 无 | ✅ visible, alpha |
### 7.2 结论
**不建议完全合并**,但可提取公共基类:
```typescript
// packages/engine-core/src/interfaces/ITransformBase.ts
export interface ITransformBase {
/** 旋转角度(度) | Rotation in degrees */
rotation: number;
/** X 缩放 | Scale X */
scaleX: number;
/** Y 缩放 | Scale Y */
scaleY: number;
/** 本地到世界矩阵 | Local to world matrix */
readonly localToWorldMatrix: Matrix2D;
/** 是否需要更新 | Dirty flag */
isDirty: boolean;
/** 世界坐标 X | World position X */
readonly worldX: number;
/** 世界坐标 Y | World position Y */
readonly worldY: number;
/** 世界旋转 | World rotation */
readonly worldRotation: number;
/** 世界缩放 X | World scale X */
readonly worldScaleX: number;
/** 世界缩放 Y | World scale Y */
readonly worldScaleY: number;
}
```
### 7.3 收益
- 渲染系统可以统一处理 `ITransformBase`
- 减少 SpriteRenderSystem 和 UIRenderSystem 的重复
- Gizmo 系统可以共享变换操作逻辑
---
## 八、向后兼容性
1. **接口兼容**:现有组件的 API 保持不变
2. **序列化兼容**:不改变现有序列化格式
3. **渐进迁移**:可分阶段进行,不影响现有功能

View File

@@ -10,6 +10,47 @@ import {
IAssetCatalogEntry
} from '../types/AssetTypes';
/**
* 纹理 Sprite 信息(从 meta 文件的 importSettings 读取)
* Texture sprite info (read from meta file's importSettings)
*/
export interface ITextureSpriteInfo {
/**
* 九宫格切片边距 [top, right, bottom, left]
* Nine-patch slice border
*/
sliceBorder?: [number, number, number, number];
/**
* Sprite 锚点 [x, y]0-1 归一化)
* Sprite pivot point (0-1 normalized)
*/
pivot?: [number, number];
/**
* 纹理宽度(可选,需要纹理已加载)
* Texture width (optional, requires texture to be loaded)
*/
width?: number;
/**
* 纹理高度(可选,需要纹理已加载)
* Texture height (optional, requires texture to be loaded)
*/
height?: number;
}
/**
* Sprite settings in import settings
* 导入设置中的 Sprite 设置
*/
interface ISpriteSettings {
sliceBorder?: [number, number, number, number];
pivot?: [number, number];
pixelsPerUnit?: number;
/** Texture width (from import settings) | 纹理宽度(来自导入设置) */
width?: number;
/** Texture height (from import settings) | 纹理高度(来自导入设置) */
height?: number;
}
/**
* Asset database implementation
* 资产数据库实现
@@ -212,6 +253,41 @@ export class AssetDatabase {
return guid ? this._metadata.get(guid) : undefined;
}
/**
* Get texture sprite info from metadata
* 从元数据获取纹理 Sprite 信息
*
* Extracts spriteSettings from importSettings if available.
* 如果可用,从 importSettings 提取 spriteSettings。
*
* @param guid - Texture asset GUID | 纹理资产 GUID
* @returns Sprite info or undefined if not found/not a texture | Sprite 信息或未找到/非纹理则为 undefined
*/
getTextureSpriteInfo(guid: AssetGUID): ITextureSpriteInfo | undefined {
const metadata = this._metadata.get(guid);
if (!metadata) return undefined;
// Check if it's a texture asset
// 检查是否是纹理资产
if (metadata.type !== AssetType.Texture) return undefined;
// Extract spriteSettings from importSettings
// 从 importSettings 提取 spriteSettings
const importSettings = metadata.importSettings as Record<string, unknown> | undefined;
const spriteSettings = importSettings?.spriteSettings as ISpriteSettings | undefined;
if (!spriteSettings) return undefined;
return {
sliceBorder: spriteSettings.sliceBorder,
pivot: spriteSettings.pivot,
// Include dimensions from import settings if available
// 如果可用,包含来自导入设置的尺寸
width: spriteSettings.width,
height: spriteSettings.height
};
}
/**
* Find assets by type
* 按类型查找资产

View File

@@ -132,7 +132,10 @@ export class AssetManager implements IAssetManager {
labels: [],
tags: new Map(),
lastModified: Date.now(),
version: 1
version: 1,
// Include importSettings for sprite slicing (nine-patch), etc.
// 包含 importSettings 以支持精灵切片(九宫格)等功能
importSettings: entry.importSettings
};
this._database.addAsset(metadata);

View File

@@ -36,6 +36,7 @@ export { RuntimeCatalog, runtimeCatalog } from './runtime/RuntimeCatalog';
export * from './interfaces/IAssetLoader';
export * from './interfaces/IAssetManager';
export * from './interfaces/IAssetReader';
export * from './interfaces/IAssetFileLoader';
export * from './interfaces/IResourceComponent';
// Core
@@ -58,13 +59,24 @@ export { PrefabLoader } from './loaders/PrefabLoader';
// Integration
export { EngineIntegration } from './integration/EngineIntegration';
export type { ITextureEngineBridge } from './integration/EngineIntegration';
export type { ITextureEngineBridge, TextureLoadCallback } from './integration/EngineIntegration';
// Services
export { SceneResourceManager } from './services/SceneResourceManager';
export type { IResourceLoader } from './services/SceneResourceManager';
export { PathResolutionService } from './services/PathResolutionService';
// Asset Metadata Service (primary API for sprite info)
// 资产元数据服务sprite 信息的主要 API
export {
setGlobalAssetDatabase,
getGlobalAssetDatabase,
setGlobalEngineBridge,
getGlobalEngineBridge,
getTextureSpriteInfo
} from './services/AssetMetadataService';
export type { ITextureSpriteInfo } from './core/AssetDatabase';
// Utils
export { UVHelper } from './utils/UVHelper';
export {

View File

@@ -31,12 +31,6 @@ export interface ITextureEngineBridge {
*/
unloadTexture(id: number): void;
/**
* Get texture info
* 获取纹理信息
*/
getTextureInfo(id: number): { width: number; height: number } | null;
/**
* Get or load texture by path.
* 按路径获取或加载纹理。
@@ -109,6 +103,20 @@ export interface ITextureEngineBridge {
* @returns Promise that resolves when texture is ready | 纹理就绪时解析的 Promise
*/
loadTextureAsync?(id: number, url: string): Promise<void>;
/**
* Get texture info by path.
* 通过路径获取纹理信息。
*
* 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
*/
getTextureInfoByPath?(path: string): { width: number; height: number } | null;
}
/**
@@ -131,10 +139,43 @@ interface DataAssetEntry {
path: string;
}
/**
* Texture load callback type
* 纹理加载回调类型
*/
export type TextureLoadCallback = (guid: string, path: string, textureId: number) => void;
/**
* Asset system engine integration
* 资产系统引擎集成
*/
/**
* Texture sprite info (nine-patch border, pivot, etc.)
* 纹理 Sprite 信息(九宫格边距、锚点等)
*/
export interface ITextureSpriteInfo {
/**
* 九宫格切片边距 [top, right, bottom, left]
* Nine-patch slice border
*/
sliceBorder?: [number, number, number, number];
/**
* Sprite 锚点 [x, y]0-1 归一化)
* Sprite pivot point (0-1 normalized)
*/
pivot?: [number, number];
/**
* 纹理宽度
* Texture width
*/
width: number;
/**
* 纹理高度
* Texture height
*/
height: number;
}
export class EngineIntegration {
private _assetManager: AssetManager;
private _engineBridge?: ITextureEngineBridge;
@@ -146,6 +187,54 @@ export class EngineIntegration {
// Path-stable ID cache (persists across Play/Stop cycles)
private static _pathIdCache = new Map<string, number>();
// 纹理 Sprite 信息缓存(全局静态,可供渲染系统访问)
// Texture sprite info cache (global static, accessible by render systems)
private static _textureSpriteInfoCache = new Map<AssetGUID, ITextureSpriteInfo>();
// 纹理加载回调(用于动态图集集成等)
// Texture load callback (for dynamic atlas integration, etc.)
private static _textureLoadCallbacks: TextureLoadCallback[] = [];
/**
* Register a callback to be called when textures are loaded
* 注册纹理加载时调用的回调
*
* This can be used for dynamic atlas integration.
* 可用于动态图集集成。
*
* @param callback - Callback function | 回调函数
*/
static onTextureLoad(callback: TextureLoadCallback): void {
if (!EngineIntegration._textureLoadCallbacks.includes(callback)) {
EngineIntegration._textureLoadCallbacks.push(callback);
}
}
/**
* Remove a texture load callback
* 移除纹理加载回调
*/
static removeTextureLoadCallback(callback: TextureLoadCallback): void {
const index = EngineIntegration._textureLoadCallbacks.indexOf(callback);
if (index >= 0) {
EngineIntegration._textureLoadCallbacks.splice(index, 1);
}
}
/**
* Notify all callbacks of a texture load
* 通知所有回调纹理已加载
*/
private static notifyTextureLoad(guid: string, path: string, textureId: number): void {
for (const callback of EngineIntegration._textureLoadCallbacks) {
try {
callback(guid, path, textureId);
} catch (e) {
console.error('[EngineIntegration] Error in texture load callback:', e);
}
}
}
// Audio resource mappings | 音频资源映射
private _audioIdMap = new Map<AssetGUID, number>();
private _pathToAudioId = new Map<string, number>();
@@ -279,6 +368,16 @@ export class EngineIntegration {
const result = await this._assetManager.loadAsset<ITextureAsset>(guid);
const metadata = result.metadata;
const assetPath = metadata.path;
const textureAsset = result.asset;
// 缓存 sprite 信息(九宫格边距等)到静态缓存
// Cache sprite info (slice border, etc.) to static cache
EngineIntegration._textureSpriteInfoCache.set(guid, {
sliceBorder: textureAsset.sliceBorder,
pivot: textureAsset.pivot,
width: textureAsset.width,
height: textureAsset.height
});
// 生成路径稳定 ID
// Generate path-stable ID
@@ -309,9 +408,37 @@ export class EngineIntegration {
this._textureIdMap.set(guid, stableId);
this._pathToTextureId.set(assetPath, stableId);
// 通知回调(用于动态图集等)
// Notify callbacks (for dynamic atlas, etc.)
EngineIntegration.notifyTextureLoad(guid, engineUrl, stableId);
return stableId;
}
/**
* Get texture sprite info by GUID (static method for render system access)
* 通过 GUID 获取纹理 Sprite 信息(静态方法,供渲染系统访问)
*
* Returns cached sprite info including nine-patch slice border.
* Must call loadTextureByGuid first to populate the cache.
* 返回缓存的 sprite 信息,包括九宫格边距。
* 必须先调用 loadTextureByGuid 来填充缓存。
*
* @param guid - Texture asset GUID | 纹理资产 GUID
* @returns Sprite info or undefined if not loaded | Sprite 信息或未加载则为 undefined
*/
static getTextureSpriteInfo(guid: AssetGUID): ITextureSpriteInfo | undefined {
return EngineIntegration._textureSpriteInfoCache.get(guid);
}
/**
* Clear texture sprite info cache
* 清除纹理 Sprite 信息缓存
*/
static clearTextureSpriteInfoCache(): void {
EngineIntegration._textureSpriteInfoCache.clear();
}
/**
* Batch load textures
* 批量加载纹理

View File

@@ -0,0 +1,103 @@
/**
* Asset File Loader Interface
* 资产文件加载器接口
*
* High-level file loading abstraction that combines path resolution
* with platform-specific file reading.
* 高级文件加载抽象,结合路径解析和平台特定的文件读取。
*
* This is the unified entry point for all file loading in the engine.
* Different from IAssetLoader (which parses content), this interface
* handles the actual file fetching from asset paths.
* 这是引擎中所有文件加载的统一入口。
* 与 IAssetLoader解析内容不同此接口处理从资产路径获取文件。
*/
/**
* Asset file loader interface.
* 资产文件加载器接口。
*
* Provides a unified API for loading files from asset paths (relative to project).
* Different platforms provide their own implementations.
* 提供从资产路径(相对于项目)加载文件的统一 API。
* 不同平台提供各自的实现。
*
* @example
* ```typescript
* // Get global loader
* const loader = getGlobalAssetFileLoader();
*
* // Load image from asset path (relative to project)
* const image = await loader.loadImage('assets/demo/button.png');
*
* // Load text content
* const json = await loader.loadText('assets/config.json');
* ```
*/
export interface IAssetFileLoader {
/**
* Load image from asset path.
* 从资产路径加载图片。
*
* @param assetPath - Asset path relative to project (e.g., "assets/demo/button.png").
* 相对于项目的资产路径。
* @returns Promise resolving to HTMLImageElement. | 返回 HTMLImageElement 的 Promise。
*/
loadImage(assetPath: string): Promise<HTMLImageElement>;
/**
* Load text content from asset path.
* 从资产路径加载文本内容。
*
* @param assetPath - Asset path relative to project. | 相对于项目的资产路径。
* @returns Promise resolving to text content. | 返回文本内容的 Promise。
*/
loadText(assetPath: string): Promise<string>;
/**
* Load binary data from asset path.
* 从资产路径加载二进制数据。
*
* @param assetPath - Asset path relative to project. | 相对于项目的资产路径。
* @returns Promise resolving to ArrayBuffer. | 返回 ArrayBuffer 的 Promise。
*/
loadBinary(assetPath: string): Promise<ArrayBuffer>;
/**
* Check if asset file exists.
* 检查资产文件是否存在。
*
* @param assetPath - Asset path relative to project. | 相对于项目的资产路径。
* @returns Promise resolving to boolean. | 返回布尔值的 Promise。
*/
exists(assetPath: string): Promise<boolean>;
}
/**
* Global asset file loader instance.
* 全局资产文件加载器实例。
*/
let globalAssetFileLoader: IAssetFileLoader | null = null;
/**
* Set the global asset file loader.
* 设置全局资产文件加载器。
*
* Should be called during engine initialization with platform-specific implementation.
* 应在引擎初始化期间使用平台特定的实现调用。
*
* @param loader - Asset file loader instance or null. | 资产文件加载器实例或 null。
*/
export function setGlobalAssetFileLoader(loader: IAssetFileLoader | null): void {
globalAssetFileLoader = loader;
}
/**
* Get the global asset file loader.
* 获取全局资产文件加载器。
*
* @returns Asset file loader instance or null. | 资产文件加载器实例或 null。
*/
export function getGlobalAssetFileLoader(): IAssetFileLoader | null {
return globalAssetFileLoader;
}

View File

@@ -144,6 +144,24 @@ export interface ITextureAsset {
hasMipmaps: boolean;
/** 原始数据(如果可用) / Raw image data if available */
data?: ImageData | HTMLImageElement;
// ===== Sprite Settings =====
// ===== Sprite 设置 =====
/**
* 九宫格切片边距 [top, right, bottom, left]
* Nine-patch slice border
*
* Defines the non-stretchable borders for nine-patch rendering.
* 定义九宫格渲染时不可拉伸的边框区域。
*/
sliceBorder?: [number, number, number, number];
/**
* Sprite 锚点 [x, y]0-1 归一化)
* Sprite pivot point (0-1 normalized)
*/
pivot?: [number, number];
}
/**
@@ -183,24 +201,109 @@ export interface IAudioAsset {
channels: number;
}
/**
* Shader property type
* 着色器属性类型
*/
export type ShaderPropertyType = 'float' | 'vec2' | 'vec3' | 'vec4' | 'int' | 'sampler2D' | 'mat3' | 'mat4';
/**
* Shader property definition
* 着色器属性定义
*/
export interface IShaderProperty {
/** 属性名称uniform 名) / Property name (uniform name) */
name: string;
/** 属性类型 / Property type */
type: ShaderPropertyType;
/** 默认值 / Default value */
default: number | number[];
/** 显示名称(编辑器用) / Display name for editor */
displayName?: string;
/** 值范围(用于 float/int / Value range for float/int */
range?: [number, number];
/** 是否隐藏(内部使用) / Hidden from inspector */
hidden?: boolean;
}
/**
* Shader asset interface
* 着色器资产接口
*
* Shader assets contain GLSL source code and property definitions.
* 着色器资产包含 GLSL 源代码和属性定义。
*/
export interface IShaderAsset {
/** 着色器名称 / Shader name (e.g., "UI/Shiny") */
name: string;
/** 顶点着色器源代码 / Vertex shader GLSL source */
vertex: string;
/** 片段着色器源代码 / Fragment shader GLSL source */
fragment: string;
/** 属性定义列表 / Property definitions */
properties: IShaderProperty[];
/** 编译后的着色器 ID运行时填充 / Compiled shader ID (runtime) */
shaderId?: number;
}
/**
* Material property value
* 材质属性值
*/
export type MaterialPropertyValue = number | number[] | string;
/**
* Material animator configuration
* 材质动画器配置
*/
export interface IMaterialAnimator {
/** 要动画的属性名 / Property to animate */
property: string;
/** 起始值 / Start value */
from: number;
/** 结束值 / End value */
to: number;
/** 持续时间(秒) / Duration in seconds */
duration: number;
/** 是否循环 / Loop animation */
loop?: boolean;
/** 循环间隔(秒) / Delay between loops */
loopDelay?: number;
/** 缓动函数 / Easing function */
easing?: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut';
/** 是否自动播放 / Auto play on start */
autoPlay?: boolean;
}
/**
* Material asset interface
* 材质资产接口
*
* Material assets reference a shader and define property values.
* 材质资产引用着色器并定义属性值。
*/
export interface IMaterialAsset {
/** 着色器名称 / Shader name */
/** 材质名称 / Material name */
name: string;
/** 着色器 GUID 或内置路径 / Shader GUID or built-in path (e.g., "builtin://shaders/Shiny") */
shader: string;
/** 材质属性 / Material properties */
properties: Map<string, unknown>;
/** 纹理映射 / Texture slot mappings */
textures: Map<string, AssetGUID>;
/** 材质属性 / Material property values */
properties: Record<string, MaterialPropertyValue>;
/** 纹理映射 / Texture slot mappings (property name -> texture GUID) */
textures?: Record<string, AssetGUID>;
/** 渲染状态 / Render states */
renderStates: {
renderStates?: {
cullMode?: 'none' | 'front' | 'back';
blendMode?: 'none' | 'alpha' | 'additive' | 'multiply';
blendMode?: 'none' | 'alpha' | 'additive' | 'multiply' | 'screen';
depthTest?: boolean;
depthWrite?: boolean;
};
/** 动画器配置(可选) / Animator configuration (optional) */
animator?: IMaterialAnimator;
/** 运行时:编译后的着色器 ID / Runtime: compiled shader ID */
_shaderId?: number;
/** 运行时:引擎材质 ID / Runtime: engine material ID */
_materialId?: number;
}
// 预制体资产接口从专用文件导出 | Prefab asset interface exported from dedicated file

View File

@@ -46,6 +46,9 @@ export class AssetLoaderFactory implements IAssetLoaderFactory {
// 预制体加载器 / Prefab loader
this._loaders.set(AssetType.Prefab, new PrefabLoader());
// 注Shader 和 Material 加载器由 material-system 模块注册
// Note: Shader and Material loaders are registered by material-system module
}
/**

View File

@@ -16,6 +16,16 @@ interface IEngineBridgeGlobal {
unloadTexture?(textureId: number): void;
}
/**
* Sprite settings from texture meta
* 纹理 meta 中的 Sprite 设置
*/
interface ISpriteSettings {
sliceBorder?: [number, number, number, number];
pivot?: [number, number];
pixelsPerUnit?: number;
}
/**
* 获取全局引擎桥接
* Get global engine bridge
@@ -61,13 +71,22 @@ export class TextureLoader implements IAssetLoader<ITextureAsset> {
const image = content.image;
// Read sprite settings from import settings
// 从导入设置读取 sprite 设置
const importSettings = context.metadata.importSettings as Record<string, unknown> | undefined;
const spriteSettings = importSettings?.spriteSettings as ISpriteSettings | undefined;
const textureAsset: ITextureAsset = {
textureId: TextureLoader._nextTextureId++,
width: image.width,
height: image.height,
format: 'rgba',
hasMipmaps: false,
data: image
data: image,
// Include sprite settings if available
// 如果有则包含 sprite 设置
sliceBorder: spriteSettings?.sliceBorder,
pivot: spriteSettings?.pivot
};
// Upload to GPU if bridge exists.

View File

@@ -0,0 +1,139 @@
/**
* Asset Metadata Service
* 资产元数据服务
*
* Provides global access to asset metadata without requiring asset loading.
* This service is independent of the texture loading path, allowing
* render systems to query sprite info regardless of how textures are loaded.
*
* 提供对资产元数据的全局访问,无需加载资产。
* 此服务独立于纹理加载路径,允许渲染系统查询 sprite 信息,
* 无论纹理是如何加载的。
*/
import { AssetDatabase, ITextureSpriteInfo } from '../core/AssetDatabase';
import type { AssetGUID } from '../types/AssetTypes';
import type { ITextureEngineBridge } from '../integration/EngineIntegration';
/**
* Global asset database instance
* 全局资产数据库实例
*/
let globalAssetDatabase: AssetDatabase | null = null;
/**
* Global engine bridge instance
* 全局引擎桥实例
*
* Used to query texture dimensions from Rust engine (single source of truth).
* 用于从 Rust 引擎查询纹理尺寸(唯一事实来源)。
*/
let globalEngineBridge: ITextureEngineBridge | null = null;
/**
* Set the global asset database
* 设置全局资产数据库
*
* Should be called during engine initialization.
* 应在引擎初始化期间调用。
*
* @param database - AssetDatabase instance | AssetDatabase 实例
*/
export function setGlobalAssetDatabase(database: AssetDatabase | null): void {
globalAssetDatabase = database;
}
/**
* Get the global asset database
* 获取全局资产数据库
*
* @returns AssetDatabase instance or null | AssetDatabase 实例或 null
*/
export function getGlobalAssetDatabase(): AssetDatabase | null {
return globalAssetDatabase;
}
/**
* Set the global engine bridge
* 设置全局引擎桥
*
* The engine bridge is used to query texture dimensions directly from Rust engine.
* This is the single source of truth for texture dimensions.
* 引擎桥用于直接从 Rust 引擎查询纹理尺寸。
* 这是纹理尺寸的唯一事实来源。
*
* @param bridge - ITextureEngineBridge instance | ITextureEngineBridge 实例
*/
export function setGlobalEngineBridge(bridge: ITextureEngineBridge | null): void {
globalEngineBridge = bridge;
}
/**
* Get the global engine bridge
* 获取全局引擎桥
*
* @returns ITextureEngineBridge instance or null | ITextureEngineBridge 实例或 null
*/
export function getGlobalEngineBridge(): ITextureEngineBridge | null {
return globalEngineBridge;
}
/**
* Get texture sprite info by GUID
* 通过 GUID 获取纹理 Sprite 信息
*
* This is the primary API for render systems to query nine-patch/sprite info.
* It combines data from:
* - Asset metadata (sliceBorder, pivot) from AssetDatabase
* - Texture dimensions (width, height) from Rust engine (single source of truth)
*
* 这是渲染系统查询九宫格/sprite 信息的主要 API。
* 它合并来自:
* - AssetDatabase 的资产元数据sliceBorder, pivot
* - Rust 引擎的纹理尺寸width, height唯一事实来源
*
* @param guid - Texture asset GUID | 纹理资产 GUID
* @returns Sprite info or undefined | Sprite 信息或 undefined
*/
export function getTextureSpriteInfo(guid: AssetGUID): ITextureSpriteInfo | undefined {
// Get sprite settings from metadata
// 从元数据获取 sprite 设置
const metadataInfo = globalAssetDatabase?.getTextureSpriteInfo(guid);
// Get texture dimensions from Rust engine (single source of truth)
// 从 Rust 引擎获取纹理尺寸(唯一事实来源)
let dimensions: { width: number; height: number } | undefined;
if (globalEngineBridge?.getTextureInfoByPath && globalAssetDatabase) {
// Get asset path from database
// 从数据库获取资产路径
const metadata = globalAssetDatabase.getMetadata(guid);
if (metadata?.path) {
const engineInfo = globalEngineBridge.getTextureInfoByPath(metadata.path);
if (engineInfo) {
dimensions = engineInfo;
}
}
}
// If no metadata and no dimensions, return undefined
// 如果没有元数据也没有尺寸,返回 undefined
if (!metadataInfo && !dimensions) {
return undefined;
}
// Merge the two sources
// 合并两个数据源
// Prefer engine dimensions (runtime loaded), fallback to metadata dimensions (catalog stored)
// 优先使用引擎尺寸(运行时加载),后备使用元数据尺寸(目录存储)
return {
sliceBorder: metadataInfo?.sliceBorder,
pivot: metadataInfo?.pivot,
width: dimensions?.width ?? metadataInfo?.width,
height: dimensions?.height ?? metadataInfo?.height
};
}
// Re-export type for convenience
// 为方便起见重新导出类型
export type { ITextureSpriteInfo };

View File

@@ -406,6 +406,12 @@ export interface IAssetCatalogEntry {
/** 可用变体 / Available variants (platform/quality specific) */
variants?: IAssetVariant[];
/**
* Import settings (e.g., sprite slicing for nine-patch)
* 导入设置(如九宫格切片信息)
*/
importSettings?: Record<string, unknown>;
}
/**

View File

@@ -1,6 +1,6 @@
import 'reflect-metadata';
export type PropertyType = 'number' | 'integer' | 'string' | 'boolean' | 'color' | 'vector2' | 'vector3' | 'vector4' | 'enum' | 'asset' | 'array' | 'animationClips' | 'collisionLayer' | 'collisionMask';
export type PropertyType = 'number' | 'integer' | 'string' | 'boolean' | 'color' | 'vector2' | 'vector3' | 'vector4' | 'enum' | 'asset' | 'array' | 'animationClips' | 'collisionLayer' | 'collisionMask' | 'entityRef';
/**
* 属性资源类型
@@ -52,6 +52,16 @@ interface PropertyOptionsBase {
label?: string;
/** 是否只读 | Read-only flag */
readOnly?: boolean;
/**
* 是否在 Inspector 中隐藏
* Whether to hide this property in Inspector
*
* Hidden properties are still serialized but not shown in the default PropertyInspector.
* Useful when a custom Inspector handles the property.
* 隐藏的属性仍然会被序列化,但不会在默认的 PropertyInspector 中显示。
* 适用于自定义 Inspector 处理该属性的情况。
*/
hidden?: boolean;
/** Action buttons | 操作按钮 */
actions?: PropertyAction[];
/** 此属性控制的其他组件属性 | Properties this field controls */
@@ -193,6 +203,17 @@ interface CollisionMaskPropertyOptions extends PropertyOptionsBase {
type: 'collisionMask';
}
/**
* 实体引用属性选项
* Entity reference property options
*
* Used for properties that store entity IDs and support drag-and-drop from SceneHierarchy.
* 用于存储实体 ID 的属性,支持从场景层级面板拖放。
*/
interface EntityRefPropertyOptions extends PropertyOptionsBase {
type: 'entityRef';
}
/**
* 属性选项联合类型
* Property options union type
@@ -208,7 +229,8 @@ export type PropertyOptions =
| ArrayPropertyOptions
| AnimationClipsPropertyOptions
| CollisionLayerPropertyOptions
| CollisionMaskPropertyOptions;
| CollisionMaskPropertyOptions
| EntityRefPropertyOptions;
// 使用 Symbol.for 创建全局 Symbol确保跨包共享元数据
// Use Symbol.for to create a global Symbol to ensure metadata sharing across packages

View File

@@ -112,6 +112,21 @@ export interface SystemMetadata {
* Whether enabled by default (default true)
*/
enabled?: boolean;
/**
* 是否在编辑模式下运行(默认 true
* Whether to run in edit mode (default true)
*
* 默认情况下,所有系统在编辑模式下都会运行。
* 当设置为 false 时,此系统在编辑模式(非 Play 状态)下不会执行。
* 适用于物理系统、AI 系统等只应在游戏运行时执行的系统。
*
* By default, all systems run in edit mode.
* When set to false, this system will NOT execute during edit mode
* (when not playing). Useful for physics, AI, and other systems
* that should only run during gameplay.
*/
runInEditMode?: boolean;
}
/**
@@ -166,6 +181,17 @@ export function getSystemMetadata(systemType: new (...args: any[]) => EntitySyst
return (systemType as any).__systemMetadata__;
}
/**
* 从系统实例获取元数据
* Get metadata from system instance
*
* @param system 系统实例 | System instance
* @returns 系统元数据 | System metadata
*/
export function getSystemInstanceMetadata(system: EntitySystem): SystemMetadata | undefined {
return getSystemMetadata(system.constructor as new (...args: any[]) => EntitySystem);
}
/**
* 获取系统类型的名称,优先使用装饰器指定的名称
* Get system type name, preferring decorator-specified name

View File

@@ -28,6 +28,7 @@ export {
getSystemTypeName,
getSystemInstanceTypeName,
getSystemMetadata,
getSystemInstanceMetadata,
SYSTEM_TYPE_NAME
} from './TypeDecorators';

View File

@@ -13,7 +13,7 @@ import { QuerySystem } from './Core/QuerySystem';
import { TypeSafeEventSystem } from './Core/EventSystem';
import { ReferenceTracker } from './Core/ReferenceTracker';
import { IScene, ISceneConfig } from './IScene';
import { getComponentInstanceTypeName, getSystemInstanceTypeName, getSystemMetadata } from './Decorators';
import { getComponentInstanceTypeName, getSystemInstanceTypeName, getSystemMetadata, getSystemInstanceMetadata } from './Decorators';
import { TypedQueryBuilder } from './Core/Query/TypedQuery';
import {
SceneSerializer,
@@ -558,7 +558,7 @@ export class Scene implements IScene {
const updateHandle = ProfilerSDK.beginSample('Systems.update', ProfileCategory.ECS);
try {
for (const system of systems) {
if (system.enabled) {
if (this._shouldSystemRun(system)) {
const systemHandle = ProfilerSDK.beginSample(system.systemName, ProfileCategory.ECS);
try {
system.update();
@@ -577,7 +577,7 @@ export class Scene implements IScene {
const lateUpdateHandle = ProfilerSDK.beginSample('Systems.lateUpdate', ProfileCategory.ECS);
try {
for (const system of systems) {
if (system.enabled) {
if (this._shouldSystemRun(system)) {
const systemHandle = ProfilerSDK.beginSample(`${system.systemName}.late`, ProfileCategory.ECS);
try {
system.lateUpdate();
@@ -602,6 +602,34 @@ export class Scene implements IScene {
}
}
/**
* 检查系统是否应该运行
* Check if a system should run
*
* @param system 要检查的系统 | System to check
* @returns 是否应该运行 | Whether it should run
*/
private _shouldSystemRun(system: EntitySystem): boolean {
// 系统必须启用
// System must be enabled
if (!system.enabled) {
return false;
}
// 非编辑模式下,所有启用的系统都运行
// In non-edit mode, all enabled systems run
if (!this.isEditorMode) {
return true;
}
// 编辑模式下,默认所有系统都运行
// 只有明确标记 runInEditMode: false 的系统不运行
// In edit mode, all systems run by default
// Only systems explicitly marked runInEditMode: false are skipped
const metadata = getSystemInstanceMetadata(system);
return metadata?.runInEditMode !== false;
}
/**
* 执行所有系统的延迟命令
* Flush all systems' deferred commands

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;

View File

@@ -162,6 +162,22 @@ function App() {
const [commandManager] = useState(() => new CommandManager());
const { t, locale, changeLocale } = useLocale();
// Play 模式状态(用于层级面板实时同步)
// Play mode state (for hierarchy panel real-time sync)
const [isPlaying, setIsPlaying] = useState(false);
// 监听 Play 状态变化
// Listen for play state changes
useEffect(() => {
if (!messageHubRef.current || !initialized) return;
const unsubscribe = messageHubRef.current.subscribe('viewport:playState:changed', (data: { isPlaying: boolean }) => {
setIsPlaying(data.isPlaying);
});
return () => unsubscribe();
}, [initialized]);
// 初始化 Store 订阅(集中管理 MessageHub 订阅)
// Initialize store subscriptions (centrally manage MessageHub subscriptions)
useStoreSubscriptions({
@@ -169,6 +185,7 @@ function App() {
entityStore: entityStoreRef.current,
sceneManager: sceneManagerRef.current,
enabled: initialized,
isPlaying,
});
// 同步 locale 到 TauriDialogService

View File

@@ -77,7 +77,8 @@ import {
Vector3FieldEditor,
Vector4FieldEditor,
ColorFieldEditor,
AnimationClipsFieldEditor
AnimationClipsFieldEditor,
EntityRefFieldEditor
} from '../../infrastructure/field-editors';
import { TransformComponentInspector } from '../../components/inspectors/component-inspectors/TransformComponentInspector';
import { buildFileSystem } from '../../services/BuildFileSystemService';
@@ -249,6 +250,7 @@ export class ServiceRegistry {
fieldEditorRegistry.register(new Vector4FieldEditor());
fieldEditorRegistry.register(new ColorFieldEditor());
fieldEditorRegistry.register(new AnimationClipsFieldEditor());
fieldEditorRegistry.register(new EntityRefFieldEditor());
// 注册组件检查器
// Register component inspectors

View File

@@ -6,6 +6,7 @@ import * as LucideIcons from 'lucide-react';
import { AnimationClipsFieldEditor } from '../infrastructure/field-editors/AnimationClipsFieldEditor';
import { AssetField } from './inspectors/fields/AssetField';
import { CollisionLayerField } from './inspectors/fields/CollisionLayerField';
import { EntityRefField } from './inspectors/fields/EntityRefField';
import { useLocale } from '../hooks/useLocale';
import '../styles/PropertyInspector.css';
@@ -339,6 +340,17 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
/>
);
case 'entityRef':
return (
<EntityRefField
key={propertyName}
label={label}
value={value ?? 0}
readonly={metadata.readOnly}
onChange={(newValue) => handleChange(propertyName, newValue)}
/>
);
case 'array': {
const arrayMeta = metadata as {
itemType?: { type: string; extensions?: string[]; assetType?: string };

View File

@@ -162,28 +162,25 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
const initialValues = new Map<string, any>();
for (const [key, descriptor] of allSettings.entries()) {
if (key.startsWith('project.') && projectService) {
if (key === 'project.uiDesignResolution.width') {
// 特定的 project 设置需要从 ProjectService 加载
// Specific project settings need to load from ProjectService
if (key === 'project.uiDesignResolution.width' && projectService) {
const resolution = projectService.getUIDesignResolution();
initialValues.set(key, resolution.width);
} else if (key === 'project.uiDesignResolution.height') {
} else if (key === 'project.uiDesignResolution.height' && projectService) {
const resolution = projectService.getUIDesignResolution();
initialValues.set(key, resolution.height);
} else if (key === 'project.uiDesignResolution.preset') {
} else if (key === 'project.uiDesignResolution.preset' && projectService) {
const resolution = projectService.getUIDesignResolution();
initialValues.set(key, `${resolution.width}x${resolution.height}`);
} else if (key === 'project.disabledModules') {
} else if (key === 'project.disabledModules' && projectService) {
// Load disabled modules from ProjectService
initialValues.set(key, projectService.getDisabledModules());
} else {
initialValues.set(key, descriptor.defaultValue);
}
} else {
// 其他设置(包括 project.dynamicAtlas.*)从 SettingsService 加载
// Other settings (including project.dynamicAtlas.*) load from SettingsService
const value = settings.get(key, descriptor.defaultValue);
initialValues.set(key, value);
if (key.startsWith('profiler.')) {
console.log(`[SettingsWindow] Loading ${key}: stored=${settings.get(key, undefined)}, default=${descriptor.defaultValue}, using=${value}`);
}
}
}
@@ -208,12 +205,23 @@ export function SettingsWindow({ onClose, settingsRegistry, initialCategoryId }:
setErrors(newErrors);
// 实时保存设置
// Real-time save settings
const settings = SettingsService.getInstance();
if (!key.startsWith('project.')) {
// 除了特定的 project 设置需要延迟保存外,其他都实时保存
// Save in real-time except for specific project settings that need deferred save
const deferredProjectSettings = [
'project.uiDesignResolution.',
'project.disabledModules'
];
const shouldDeferSave = deferredProjectSettings.some(prefix => key.startsWith(prefix));
if (!shouldDeferSave) {
settings.set(key, value);
console.log(`[SettingsWindow] Saved ${key}:`, value);
// 触发设置变更事件
// Trigger settings changed event
window.dispatchEvent(new CustomEvent('settings:changed', {
detail: { [key]: value }
}));

View File

@@ -321,6 +321,15 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
scaleSnapRef.current = scaleSnapValue;
}, [playState, camera2DZoom, camera2DOffset, transformMode, snapEnabled, gridSnapValue, rotationSnapValue, scaleSnapValue]);
// 发布 Play 状态变化事件,用于层级面板实时同步
// Publish play state change event for hierarchy panel real-time sync
useEffect(() => {
messageHub?.publish('viewport:playState:changed', {
playState,
isPlaying: playState === 'playing'
});
}, [playState, messageHub]);
// Snap helper functions
const snapToGrid = useCallback((value: number): number => {
if (!snapEnabledRef.current || gridSnapRef.current <= 0) return value;
@@ -376,6 +385,7 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
}
}, []);
// Sync commandManager prop to ref | 同步 commandManager prop 到 ref
useEffect(() => {
commandManagerRef.current = commandManager ?? null;
@@ -438,7 +448,33 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
// Left button (0) for transform or camera pan (if no transform mode active)
else if (e.button === 0) {
if (transformModeRef.current === 'select') {
// In select mode, left click pans camera
// In select mode, first check if clicking on a gizmo
// 在选择模式下,首先检查是否点击了 gizmo
const gizmoService = EngineService.getInstance().getGizmoInteractionService();
if (gizmoService) {
const worldPos = screenToWorld(e.clientX, e.clientY);
const zoom = camera2DZoomRef.current;
const hitEntityId = gizmoService.handleClick(worldPos.x, worldPos.y, zoom);
if (hitEntityId !== null) {
// Find and select the hit entity
// 找到并选中命中的实体
const scene = Core.scene;
if (scene) {
const hitEntity = scene.entities.findEntityById(hitEntityId);
if (hitEntity && messageHubRef.current) {
const entityStore = Core.services.tryResolve(EntityStoreService);
entityStore?.selectEntity(hitEntity);
messageHubRef.current.publish('entity:selected', { entity: hitEntity });
e.preventDefault();
return; // Don't start camera pan
}
}
}
}
// No gizmo hit, left click pans camera
// 没有点击到 gizmo左键拖动相机
isDraggingCameraRef.current = true;
canvas.style.cursor = 'grabbing';
} else {
@@ -478,6 +514,7 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
x: prev.x - (deltaX * dpr) / zoom,
y: prev.y + (deltaY * dpr) / zoom
}));
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
} else if (isDraggingTransformRef.current) {
// Transform selected entity based on mode
const entity = selectedEntityRef.current;
@@ -592,11 +629,30 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
});
}
}
} else {
return;
}
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
} else {
// Not dragging - update gizmo hover state
// 没有拖拽时 - 更新 gizmo 悬停状态
if (playStateRef.current !== 'playing') {
const gizmoService = EngineService.getInstance().getGizmoInteractionService();
if (gizmoService) {
const worldPos = screenToWorld(e.clientX, e.clientY);
const zoom = camera2DZoomRef.current;
gizmoService.updateMousePosition(worldPos.x, worldPos.y, zoom);
// Update cursor based on hover state
// 根据悬停状态更新光标
const hoveredId = gizmoService.getHoveredEntityId();
if (hoveredId !== null) {
canvas.style.cursor = 'pointer';
} else {
canvas.style.cursor = 'grab';
}
}
}
return;
}
};
const handleMouseUp = () => {
@@ -904,8 +960,19 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
await EngineService.getInstance().loadSceneResources();
// 同步 EntityStore 并通知层级面板更新
// Sync EntityStore and notify hierarchy panel to update
const entityStore = Core.services.tryResolve(EntityStoreService);
entityStore?.syncFromScene();
// 发布运行时场景切换事件,通知层级面板更新
// Publish runtime scene change event to notify hierarchy panel
const sceneName = fullPath.split(/[/\\]/).pop()?.replace('.ecs', '') || 'Unknown';
messageHub?.publish('runtime:scene:changed', {
path: fullPath,
sceneName,
isPlayMode: true
});
}
console.log(`[Viewport] Scene loaded in play mode: ${scenePath}`);
@@ -1167,7 +1234,7 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
// Build asset catalog and copy files
// 构建资产目录并复制文件
const catalogEntries: Record<string, { guid: string; path: string; type: string; size: number; hash: string }> = {};
const catalogEntries: Record<string, { guid: string; path: string; type: string; size: number; hash: string; importSettings?: Record<string, unknown> }> = {};
for (const assetPath of assetPaths) {
if (!assetPath || (!assetPath.includes(':\\') && !assetPath.startsWith('/'))) continue;
@@ -1180,11 +1247,11 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
}
// Get filename and determine relative path
// 路径格式:相对于 assets 目录,不包含 'assets/' 前缀
// Path format: relative to assets directory, without 'assets/' prefix
// 路径格式:包含 'assets/' 前缀,与运行时资产加载器格式一致
// Path format: includes 'assets/' prefix, consistent with runtime asset loader
const filename = assetPath.split(/[/\\]/).pop() || '';
const destPath = `${assetsDir}\\${filename}`;
const relativePath = filename;
const relativePath = `assets/${filename}`;
// Copy file
await TauriAPI.copyFile(assetPath, destPath);
@@ -1206,6 +1273,7 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
// 检查此资产是否通过 GUID 引用(如粒子资产)
// 如果是,使用原始 GUID否则根据路径生成
let guid: string | undefined;
let importSettings: Record<string, unknown> | undefined;
for (const [originalGuid, mappedPath] of guidToPath.entries()) {
if (mappedPath === assetPath) {
guid = originalGuid;
@@ -1216,12 +1284,61 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport
guid = assetPath.replace(/[^a-zA-Z0-9]/g, '-').substring(0, 36);
}
// Get importSettings from meta file for nine-patch and other settings
// 从 meta 文件获取 importSettings用于九宫格和其他设置
if (assetRegistry) {
try {
const meta = await assetRegistry.metaManager.getOrCreateMeta(assetPath);
if (meta.importSettings) {
importSettings = meta.importSettings as Record<string, unknown>;
}
} catch {
// Meta file may not exist, that's ok
}
}
// For texture assets, read image dimensions and store in importSettings
// 对于纹理资产,读取图片尺寸并存储到 importSettings
if (assetType === 'texture') {
try {
// Read image as base64 and get dimensions
// 读取图片为 base64 并获取尺寸
const base64Data = await TauriAPI.readFileAsBase64(assetPath);
const dimensions = await new Promise<{ width: number; height: number }>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
img.onerror = () => reject(new Error('Failed to load image'));
img.src = `data:image/${ext.slice(1)};base64,${base64Data}`;
});
// Ensure importSettings and spriteSettings exist
// 确保 importSettings 和 spriteSettings 存在
if (!importSettings) {
importSettings = {};
}
if (!importSettings.spriteSettings) {
importSettings.spriteSettings = {};
}
// Add dimensions to spriteSettings
// 将尺寸添加到 spriteSettings
const spriteSettings = importSettings.spriteSettings as Record<string, unknown>;
spriteSettings.width = dimensions.width;
spriteSettings.height = dimensions.height;
console.log(`[Viewport] Texture ${filename}: ${dimensions.width}x${dimensions.height}`);
} catch (dimError) {
console.warn(`[Viewport] Failed to get dimensions for ${filename}:`, dimError);
}
}
catalogEntries[guid] = {
guid,
path: relativePath,
type: assetType,
size: 0,
hash: ''
hash: '',
importSettings
};
} catch (error) {
console.error(`[Viewport] Failed to copy asset ${assetPath}:`, error);

View File

@@ -399,6 +399,24 @@
flex-shrink: 0;
}
/* Batch breaker item highlight */
.event-item.batch-breaker {
background: rgba(245, 158, 11, 0.08);
}
.event-item.batch-breaker:hover {
background: rgba(245, 158, 11, 0.12);
}
.event-item .event-name.batch-breaker {
color: #f59e0b;
font-weight: 500;
}
.event-item .event-icon.breaker {
color: #f59e0b;
}
/* ==================== Right Panel ==================== */
.render-debug-right {
flex: 1;
@@ -536,6 +554,28 @@
font-weight: 600;
}
/* Batch fix tip */
.batch-fix-tip {
padding: 8px 10px;
background: rgba(255, 193, 7, 0.1);
border: 1px solid rgba(255, 193, 7, 0.3);
border-radius: 4px;
color: #ffc107;
font-size: 10px;
line-height: 1.4;
margin-top: 4px;
}
/* Batch breaker warning */
.batch-breaker-warning {
color: #f59e0b !important;
background: rgba(245, 158, 11, 0.15);
border-radius: 3px;
padding: 4px 8px !important;
margin: 0 !important;
border-top: none !important;
}
/* ==================== Stats Bar ==================== */
.render-debug-stats {
display: flex;
@@ -631,3 +671,147 @@
word-break: break-all;
line-height: 1.3;
}
/* ==================== Clickable Stats ==================== */
.render-debug-stats .stat-item.clickable {
cursor: pointer;
padding: 2px 6px;
border-radius: 3px;
transition: background 0.15s;
}
.render-debug-stats .stat-item.clickable:hover {
background: #3a3a3a;
}
.render-debug-stats .stat-item.atlas-enabled {
color: #10b981;
}
.render-debug-stats .stat-item.atlas-disabled {
color: #666;
}
/* ==================== Atlas Preview Modal ==================== */
.atlas-preview-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.atlas-preview-content {
background: #1e1e1e;
border: 1px solid #3c3c3c;
border-radius: 8px;
width: 600px;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.atlas-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: #2d2d2d;
border-bottom: 1px solid #1a1a1a;
font-weight: 500;
}
.atlas-page-tabs {
display: flex;
gap: 4px;
padding: 8px 12px;
background: #252525;
border-bottom: 1px solid #1a1a1a;
}
.atlas-page-tab {
padding: 4px 10px;
background: #333;
border: 1px solid #444;
border-radius: 4px;
color: #aaa;
font-size: 10px;
cursor: pointer;
transition: all 0.15s;
}
.atlas-page-tab:hover {
background: #3a3a3a;
color: #ccc;
}
.atlas-page-tab.active {
background: #4a9eff;
border-color: #4a9eff;
color: #fff;
}
.atlas-preview-canvas-container {
flex: 1;
min-height: 350px;
padding: 12px;
background: #1a1a1a;
}
.atlas-preview-canvas-container canvas {
width: 100%;
height: 100%;
cursor: crosshair;
}
.atlas-preview-info {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 10px 14px;
background: #252525;
border-top: 1px solid #1a1a1a;
min-height: 40px;
}
.atlas-preview-info .hint {
color: #666;
font-style: italic;
}
.atlas-entry-info {
display: flex;
gap: 6px;
font-size: 10px;
}
.atlas-entry-info .label {
color: #888;
}
.atlas-entry-info .value {
color: #4a9eff;
font-family: 'Consolas', monospace;
}
.atlas-preview-stats {
display: flex;
flex-wrap: wrap;
gap: 16px;
padding: 8px 14px;
background: #2d2d2d;
border-top: 1px solid #1a1a1a;
font-size: 10px;
color: #888;
}
.atlas-preview-stats .error {
color: #ef4444;
}

View File

@@ -26,18 +26,21 @@ import {
Download,
Radio,
Square,
Type
Type,
Grid3x3
} from 'lucide-react';
import { WebviewWindow, getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { emit, emitTo, listen, type UnlistenFn } from '@tauri-apps/api/event';
import { renderDebugService, type RenderDebugSnapshot, type SpriteDebugInfo, type ParticleDebugInfo, type UIDebugInfo } from '../../services/RenderDebugService';
import { renderDebugService, type RenderDebugSnapshot, type SpriteDebugInfo, type ParticleDebugInfo, type UIDebugInfo, type UniformDebugValue, type AtlasStats, type AtlasPageDebugInfo, type AtlasEntryDebugInfo } from '../../services/RenderDebugService';
import type { BatchDebugInfo } from '@esengine/ui';
import { EngineService } from '../../services/EngineService';
import './RenderDebugPanel.css';
/**
* 渲染事件类型
* Render event type
*/
type RenderEventType = 'clear' | 'sprite' | 'particle' | 'ui' | 'batch' | 'draw';
type RenderEventType = 'clear' | 'sprite' | 'particle' | 'ui' | 'batch' | 'draw' | 'ui-batch';
/**
* 渲染事件
@@ -52,6 +55,8 @@ interface RenderEvent {
data?: SpriteDebugInfo | ParticleDebugInfo | UIDebugInfo | any;
drawCalls?: number;
vertices?: number;
/** 合批调试信息 | Batch debug info */
batchInfo?: BatchDebugInfo;
}
interface RenderDebugPanelProps {
@@ -74,6 +79,10 @@ export const RenderDebugPanel: React.FC<RenderDebugPanelProps> = ({ visible, onC
const [frameHistory, setFrameHistory] = useState<RenderDebugSnapshot[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1); // -1 表示实时模式 | -1 means live mode
// 图集预览状态 | Atlas preview state
const [showAtlasPreview, setShowAtlasPreview] = useState(false);
const [selectedAtlasPage, setSelectedAtlasPage] = useState(0);
// 窗口拖动状态 | Window drag state
const [position, setPosition] = useState({ x: 100, y: 60 });
const [size, setSize] = useState({ width: 900, height: 600 });
@@ -84,6 +93,39 @@ export const RenderDebugPanel: React.FC<RenderDebugPanelProps> = ({ visible, onC
const canvasRef = useRef<HTMLCanvasElement>(null);
const windowRef = useRef<HTMLDivElement>(null);
// 高亮相关 | Highlight related
const previousSelectedIdsRef = useRef<number[] | null>(null);
const engineService = useRef(EngineService.getInstance());
// 处理事件选中并高亮实体 | Handle event selection and highlight entity
const handleEventSelect = useCallback((event: RenderEvent | null) => {
setSelectedEvent(event);
// 获取实体 ID | Get entity ID
const entityId = event?.data?.entityId;
if (entityId !== undefined) {
// 保存原始选中状态(只保存一次)| Save original selection (only once)
if (previousSelectedIdsRef.current === null) {
previousSelectedIdsRef.current = engineService.current.getSelectedEntityIds?.() || [];
}
// 高亮选中的实体 | Highlight selected entity
engineService.current.setSelectedEntityIds([entityId]);
} else if (previousSelectedIdsRef.current !== null) {
// 恢复原始选中状态 | Restore original selection
engineService.current.setSelectedEntityIds(previousSelectedIdsRef.current);
previousSelectedIdsRef.current = null;
}
}, []);
// 面板关闭时恢复原始选中状态 | Restore original selection when panel closes
useEffect(() => {
if (!visible && previousSelectedIdsRef.current !== null) {
engineService.current.setSelectedEntityIds(previousSelectedIdsRef.current);
previousSelectedIdsRef.current = null;
}
}, [visible]);
// 弹出为独立窗口 | Pop out to separate window
const handlePopOut = useCallback(async () => {
try {
@@ -181,8 +223,85 @@ export const RenderDebugPanel: React.FC<RenderDebugPanelProps> = ({ visible, onC
});
});
// UI 元素 | UI elements
if (snap.uiElements && snap.uiElements.length > 0) {
// UI 批次和元素 | UI batches and elements
// 使用 entityIds 进行精确的批次-元素匹配 | Use entityIds for precise batch-element matching
if (snap.uiBatches && snap.uiBatches.length > 0) {
const uiChildren: RenderEvent[] = [];
// 构建 entityId -> UI 元素的映射 | Build entityId -> UI element map
const uiElementMap = new Map<number, UIDebugInfo>();
snap.uiElements?.forEach(ui => {
if (ui.entityId !== undefined) {
uiElementMap.set(ui.entityId, ui);
}
});
// 为每个批次创建事件,包含其子元素 | Create events for each batch with its child elements
snap.uiBatches.forEach((batch) => {
const reasonLabels: Record<string, string> = {
'first': '',
'sortingLayer': '⚠️ Layer',
'texture': '⚠️ Texture',
'material': '⚠️ Material'
};
const reasonLabel = reasonLabels[batch.reason] || '';
const batchName = batch.reason === 'first'
? `DC ${batch.batchIndex}: ${batch.primitiveCount} prims`
: `DC ${batch.batchIndex} ${reasonLabel}: ${batch.primitiveCount} prims`;
// 从 entityIds 获取此批次的 UI 元素 | Get UI elements for this batch from entityIds
const batchElements: RenderEvent[] = [];
const entityIds = batch.entityIds ?? [];
const firstEntityId = batch.firstEntityId;
entityIds.forEach((entityId) => {
const ui = uiElementMap.get(entityId);
if (ui) {
// 使用 firstEntityId 精确标记打断批次的元素 | Use firstEntityId to precisely mark batch breaker
const isBreaker = entityId === firstEntityId && batch.reason !== 'first';
batchElements.push({
id: eventId++,
type: 'ui' as RenderEventType,
name: isBreaker
? `${ui.type}: ${ui.entityName}`
: `${ui.type}: ${ui.entityName}`,
data: {
...ui,
isBatchBreaker: isBreaker,
breakReason: isBreaker ? batch.reason : undefined,
batchIndex: batch.batchIndex
},
drawCalls: 0,
vertices: 4
});
}
});
uiChildren.push({
id: eventId++,
type: 'ui-batch' as RenderEventType,
name: batchName,
batchInfo: batch,
children: batchElements.length > 0 ? batchElements : undefined,
expanded: batchElements.length > 0 && batchElements.length <= 10,
drawCalls: 1,
vertices: batch.primitiveCount * 4
});
});
const totalPrimitives = snap.uiBatches.reduce((sum, b) => sum + b.primitiveCount, 0);
const dcCount = snap.uiBatches.length;
newEvents.push({
id: eventId++,
type: 'batch',
name: `UI Render (${dcCount} DC, ${snap.uiElements?.length ?? 0} elements)`,
children: uiChildren,
expanded: true,
drawCalls: dcCount,
vertices: totalPrimitives * 4
});
} else if (snap.uiElements && snap.uiElements.length > 0) {
// 回退:没有批次信息时按元素显示 | Fallback: show by element when no batch info
const uiChildren: RenderEvent[] = snap.uiElements.map((ui) => ({
id: eventId++,
type: 'ui' as RenderEventType,
@@ -234,9 +353,9 @@ export const RenderDebugPanel: React.FC<RenderDebugPanelProps> = ({ visible, onC
if (snap) {
setSnapshot(snap);
setEvents(buildEventsFromSnapshot(snap));
setSelectedEvent(null);
handleEventSelect(null);
}
}, [frameHistory, buildEventsFromSnapshot]);
}, [frameHistory, buildEventsFromSnapshot, handleEventSelect]);
// 返回实时模式 | Return to live mode
const goLive = useCallback(() => {
@@ -467,27 +586,82 @@ export const RenderDebugPanel: React.FC<RenderDebugPanelProps> = ({ visible, onC
ctx.textAlign = 'left';
ctx.fillText(`${particles.length} particles sampled`, margin, rect.height - 6);
} else if (data?.uv) {
// Sprite 或单个粒子:显示 UV 区域 | Sprite or single particle: show UV region
const uv = data.uv;
const previewSize = Math.min(viewWidth, viewHeight);
} else if (data?.uv || data?.textureUrl) {
// Sprite 或 UI 元素:显示纹理和 UV 区域 | Sprite or UI element: show texture and UV region
const uv = data.uv ?? [0, 0, 1, 1];
const previewSize = Math.min(viewWidth, viewHeight) - 30; // 留出底部文字空间
const offsetX = (rect.width - previewSize) / 2;
const offsetY = (rect.height - previewSize) / 2;
const offsetY = margin;
// 绘制纹理边框 | Draw texture border
// 绘制棋盘格背景(透明度指示)| Draw checkerboard background (transparency indicator)
const checkerSize = 8;
for (let cx = 0; cx < previewSize; cx += checkerSize) {
for (let cy = 0; cy < previewSize; cy += checkerSize) {
const isLight = ((cx / checkerSize) + (cy / checkerSize)) % 2 === 0;
ctx.fillStyle = isLight ? '#2a2a2a' : '#1f1f1f';
ctx.fillRect(offsetX + cx, offsetY + cy, checkerSize, checkerSize);
}
}
// 如果有纹理 URL加载并绘制纹理 | If texture URL exists, load and draw texture
if (data.textureUrl) {
const img = document.createElement('img');
img.onload = () => {
// 重新获取 context异步回调中需要| Re-get context (needed in async callback)
const ctx2 = canvas.getContext('2d');
if (!ctx2) return;
ctx2.scale(window.devicePixelRatio, window.devicePixelRatio);
// 绘制纹理 | Draw texture
ctx2.drawImage(img, offsetX, offsetY, previewSize, previewSize);
// 高亮 UV 区域 | Highlight UV region
const x = offsetX + uv[0] * previewSize;
const y = offsetY + uv[1] * previewSize;
const w = (uv[2] - uv[0]) * previewSize;
const h = (uv[3] - uv[1]) * previewSize;
ctx2.fillStyle = 'rgba(74, 158, 255, 0.2)';
ctx2.fillRect(x, y, w, h);
ctx2.strokeStyle = '#4a9eff';
ctx2.lineWidth = 2;
ctx2.strokeRect(x, y, w, h);
// 绘制边框 | Draw border
ctx2.strokeStyle = '#444';
ctx2.lineWidth = 1;
ctx2.strokeRect(offsetX, offsetY, previewSize, previewSize);
// 显示信息 | Show info
ctx2.fillStyle = '#4a9eff';
ctx2.font = '10px Consolas, monospace';
ctx2.textAlign = 'left';
const infoY = offsetY + previewSize + 14;
ctx2.fillText(`UV: [${uv.map((v: number) => v.toFixed(3)).join(', ')}]`, offsetX, infoY);
if (data.aspectRatio !== undefined) {
ctx2.fillStyle = '#10b981';
ctx2.fillText(`aspectRatio: ${data.aspectRatio.toFixed(4)}`, offsetX + 180, infoY);
}
if (data.color) {
ctx2.fillStyle = '#f59e0b';
ctx2.fillText(`color: ${data.color}`, offsetX, infoY + 12);
}
};
img.src = data.textureUrl;
} else {
// 没有纹理时绘制占位符 | Draw placeholder when no texture
ctx.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.strokeRect(offsetX, offsetY, previewSize, previewSize);
// 如果是粒子帧,显示 TextureSheet 网格 | If particle frame, show TextureSheet grid
const tilesX = data._animTilesX ?? (data.systemName ? 1 : 1);
const tilesX = data._animTilesX ?? 1;
const tilesY = data._animTilesY ?? 1;
if (tilesX > 1 || tilesY > 1) {
const cellWidth = previewSize / tilesX;
const cellHeight = previewSize / tilesY;
// 绘制网格 | Draw grid
ctx.strokeStyle = '#2a2a2a';
for (let i = 0; i <= tilesX; i++) {
ctx.beginPath();
@@ -515,14 +689,19 @@ export const RenderDebugPanel: React.FC<RenderDebugPanelProps> = ({ visible, onC
ctx.lineWidth = 2;
ctx.strokeRect(x, y, w, h);
// 显示 UV 坐标 | Show UV coordinates
// 显示信息 | Show info
ctx.fillStyle = '#4a9eff';
ctx.font = '10px Consolas, monospace';
ctx.textAlign = 'left';
ctx.fillText(`UV: [${uv.map((v: number) => v.toFixed(3)).join(', ')}]`, offsetX, offsetY + previewSize + 14);
const infoY = offsetY + previewSize + 14;
ctx.fillText(`UV: [${uv.map((v: number) => v.toFixed(3)).join(', ')}]`, offsetX, infoY);
if (data.aspectRatio !== undefined) {
ctx.fillStyle = '#10b981';
ctx.fillText(`aspectRatio: ${data.aspectRatio.toFixed(4)}`, offsetX + 180, infoY);
}
if (data.frame !== undefined) {
ctx.fillText(`Frame: ${data.frame}`, offsetX, offsetY + previewSize + 26);
ctx.fillText(`Frame: ${data.frame}`, offsetX, infoY + 12);
}
}
} else {
// 其他事件类型 | Other event types
@@ -707,7 +886,7 @@ export const RenderDebugPanel: React.FC<RenderDebugPanelProps> = ({ visible, onC
event={event}
depth={0}
selected={selectedEvent?.id === event.id}
onSelect={setSelectedEvent}
onSelect={handleEventSelect}
onToggle={toggleExpand}
/>
))
@@ -767,10 +946,39 @@ export const RenderDebugPanel: React.FC<RenderDebugPanelProps> = ({ visible, onC
<Image size={12} />
<span>Systems: {snapshot?.particles?.length ?? 0}</span>
</div>
{/* 动态图集统计 | Dynamic atlas stats */}
{snapshot?.atlasStats && (
<div
className={`stat-item clickable ${snapshot.atlasStats.enabled ? 'atlas-enabled' : 'atlas-disabled'}`}
title={
snapshot.atlasStats.enabled
? `Click to view atlas. ${snapshot.atlasStats.pageCount} pages, ${snapshot.atlasStats.textureCount} textures, ${(snapshot.atlasStats.averageOccupancy * 100).toFixed(0)}% occupancy`
: 'Dynamic Atlas: Disabled'
}
onClick={() => snapshot.atlasStats?.enabled && setShowAtlasPreview(true)}
>
<Grid3x3 size={12} />
<span>
Atlas: {snapshot.atlasStats.enabled
? `${snapshot.atlasStats.textureCount}/${snapshot.atlasStats.pageCount}p`
: 'Off'}
</span>
</div>
)}
</div>
{/* 调整大小手柄(独立模式下隐藏)| Resize handle (hidden in standalone mode) */}
{!standalone && <div className="resize-handle" onMouseDown={handleResizeMouseDown} />}
{/* 图集预览弹窗 | Atlas preview modal */}
{showAtlasPreview && snapshot?.atlasStats?.pages && (
<AtlasPreviewModal
atlasStats={snapshot.atlasStats}
selectedPage={selectedAtlasPage}
onSelectPage={setSelectedAtlasPage}
onClose={() => setShowAtlasPreview(false)}
/>
)}
</div>
);
};
@@ -788,12 +996,14 @@ interface EventItemProps {
const EventItem: React.FC<EventItemProps> = ({ event, depth, selected, onSelect, onToggle }) => {
const hasChildren = event.children && event.children.length > 0;
const iconSize = 12;
const isBatchBreaker = event.data?.isBatchBreaker === true;
const getTypeIcon = () => {
switch (event.type) {
case 'sprite': return <Image size={iconSize} className="event-icon sprite" />;
case 'particle': return <Sparkles size={iconSize} className="event-icon particle" />;
case 'ui': return <Square size={iconSize} className="event-icon ui" />;
case 'ui': return <Square size={iconSize} className={`event-icon ui ${isBatchBreaker ? 'breaker' : ''}`} />;
case 'ui-batch': return <Layers size={iconSize} className="event-icon ui" />;
case 'batch': return <Layers size={iconSize} className="event-icon batch" />;
default: return <Monitor size={iconSize} className="event-icon" />;
}
@@ -802,7 +1012,7 @@ const EventItem: React.FC<EventItemProps> = ({ event, depth, selected, onSelect,
return (
<>
<div
className={`event-item ${selected ? 'selected' : ''}`}
className={`event-item ${selected ? 'selected' : ''} ${isBatchBreaker ? 'batch-breaker' : ''}`}
style={{ paddingLeft: 8 + depth * 16 }}
onClick={() => onSelect(event)}
>
@@ -814,8 +1024,8 @@ const EventItem: React.FC<EventItemProps> = ({ event, depth, selected, onSelect,
<span className="expand-icon placeholder" />
)}
{getTypeIcon()}
<span className="event-name">{event.name}</span>
{event.drawCalls !== undefined && (
<span className={`event-name ${isBatchBreaker ? 'batch-breaker' : ''}`}>{event.name}</span>
{event.drawCalls !== undefined && event.drawCalls > 0 && (
<span className="event-draws">{event.drawCalls}</span>
)}
</div>
@@ -948,6 +1158,8 @@ const EventDetails: React.FC<EventDetailsProps> = ({ event }) => {
});
}, [event, data]);
const batchInfo = event.batchInfo;
return (
<div className="details-grid">
<DetailRow label="Event" value={event.name} />
@@ -955,6 +1167,48 @@ const EventDetails: React.FC<EventDetailsProps> = ({ event }) => {
<DetailRow label="Draw Calls" value={event.drawCalls?.toString() ?? '-'} />
<DetailRow label="Vertices" value={event.vertices?.toString() ?? '-'} />
{/* UI 批次信息 | UI batch info */}
{event.type === 'ui-batch' && batchInfo && (
<>
<div className="details-section">Batch Break Reason</div>
<DetailRow
label="Reason"
value={batchInfo.reason === 'first' ? 'First batch' : batchInfo.reason}
highlight={batchInfo.reason !== 'first'}
/>
<DetailRow label="Detail" value={batchInfo.detail} />
<div className="details-section">Batch Properties</div>
<DetailRow label="Batch Index" value={batchInfo.batchIndex.toString()} />
<DetailRow label="Primitives" value={batchInfo.primitiveCount.toString()} />
<DetailRow label="Sorting Layer" value={batchInfo.sortingLayer} />
<DetailRow label="Order" value={batchInfo.orderInLayer.toString()} />
<DetailRow
label="Texture"
value={batchInfo.textureKey.startsWith('atlas:')
? `🗂️ ${batchInfo.textureKey}`
: batchInfo.textureKey}
highlight={batchInfo.textureKey.startsWith('atlas:')}
/>
<DetailRow label="Material ID" value={batchInfo.materialId.toString()} />
{batchInfo.reason !== 'first' && (
<>
<div className="details-section">How to Fix</div>
<div className="batch-fix-tip">
{batchInfo.reason === 'sortingLayer' && (
<span></span>
)}
{batchInfo.reason === 'texture' && (
<span>使</span>
)}
{batchInfo.reason === 'material' && (
<span>使/</span>
)}
</div>
</>
)}
</>
)}
{data && (
<>
<div className="details-section">Properties</div>
@@ -971,6 +1225,17 @@ const EventDetails: React.FC<EventDetailsProps> = ({ event }) => {
<DetailRow label="Sort Layer" value={data.sortingLayer || 'Default'} />
<DetailRow label="Order" value={data.orderInLayer?.toString() ?? '0'} />
<DetailRow label="Alpha" value={data.alpha?.toFixed(2) ?? '1.00'} />
<div className="details-section">Material</div>
<DetailRow label="Shader" value={data.shaderName ?? 'DefaultSprite'} highlight />
<DetailRow label="Shader ID" value={data.materialId?.toString() ?? '0'} />
{data.uniforms && Object.keys(data.uniforms).length > 0 && (
<>
<div className="details-section">Uniforms</div>
<UniformList uniforms={data.uniforms} />
</>
)}
<div className="details-section">Vertex Attributes</div>
<DetailRow label="aspectRatio" value={data.aspectRatio?.toFixed(4) ?? '1.0000'} highlight />
</>
)}
@@ -1018,6 +1283,19 @@ const EventDetails: React.FC<EventDetailsProps> = ({ event }) => {
{/* UI 元素数据 | UI element data */}
{event.type === 'ui' && data.entityName && (
<>
{/* 如果是打断合批的元素,显示警告 | Show warning if this element breaks batching */}
{data.isBatchBreaker && (
<>
<div className="details-section batch-breaker-warning"> Batch Breaker</div>
<div className="batch-fix-tip">
Draw Call
{data.breakReason === 'sortingLayer' && ' 原因:排序层与前一个元素不同。'}
{data.breakReason === 'orderInLayer' && ' 原因:层内顺序与前一个元素不同。'}
{data.breakReason === 'texture' && ' 原因:纹理与前一个元素不同。'}
{data.breakReason === 'material' && ' 原因:材质/着色器与前一个元素不同。'}
</div>
</>
)}
<DetailRow label="Entity" value={data.entityName} />
<DetailRow label="Type" value={data.type} highlight />
<DetailRow label="Position" value={`(${data.x?.toFixed(0)}, ${data.y?.toFixed(0)})`} />
@@ -1026,14 +1304,20 @@ const EventDetails: React.FC<EventDetailsProps> = ({ event }) => {
<DetailRow label="Rotation" value={`${((data.rotation ?? 0) * 180 / Math.PI).toFixed(1)}°`} />
<DetailRow label="Visible" value={data.visible ? 'Yes' : 'No'} />
<DetailRow label="Alpha" value={data.alpha?.toFixed(2) ?? '1.00'} />
<DetailRow label="Sort Layer" value={data.sortingLayer || 'UI'} />
<div className="details-section">Sorting</div>
<DetailRow label="Sort Layer" value={data.sortingLayer || 'UI'} highlight={data.isBatchBreaker && data.breakReason === 'sortingLayer'} />
<DetailRow label="Order" value={data.orderInLayer?.toString() ?? '0'} />
<DetailRow label="Depth" value={data.depth?.toString() ?? '0'} />
<DetailRow label="World Order" value={data.worldOrderInLayer?.toString() ?? '0'} highlight />
{data.backgroundColor && (
<DetailRow label="Background" value={data.backgroundColor} />
)}
{data.textureGuid && (
<TexturePreview textureUrl={data.textureUrl} texturePath={data.textureGuid} />
)}
{!data.textureGuid && data.isBatchBreaker && data.breakReason === 'texture' && (
<DetailRow label="Texture" value="(none / solid)" highlight />
)}
{data.text && (
<>
<div className="details-section">Text</div>
@@ -1041,6 +1325,17 @@ const EventDetails: React.FC<EventDetailsProps> = ({ event }) => {
{data.fontSize && <DetailRow label="Font Size" value={data.fontSize.toString()} />}
</>
)}
<div className="details-section">Material</div>
<DetailRow label="Shader" value={data.shaderName ?? 'DefaultSprite'} highlight={data.isBatchBreaker && data.breakReason === 'material'} />
<DetailRow label="Shader ID" value={data.materialId?.toString() ?? '0'} highlight={data.isBatchBreaker && data.breakReason === 'material'} />
{data.uniforms && Object.keys(data.uniforms).length > 0 && (
<>
<div className="details-section">Uniforms</div>
<UniformList uniforms={data.uniforms} />
</>
)}
<div className="details-section">Vertex Attributes</div>
<DetailRow label="aspectRatio" value={data.aspectRatio?.toFixed(4) ?? '1.0000'} highlight />
</>
)}
</>
@@ -1056,4 +1351,350 @@ const DetailRow: React.FC<{ label: string; value: string; highlight?: boolean }>
</div>
);
/**
* 格式化 uniform 值
* Format uniform value
*/
function formatUniformValue(uniform: UniformDebugValue): string {
const { type, value } = uniform;
if (typeof value === 'number') {
return type === 'int' ? value.toString() : value.toFixed(4);
}
if (Array.isArray(value)) {
return value.map(v => v.toFixed(3)).join(', ');
}
return String(value);
}
/**
* Uniform 列表组件
* Uniform list component
*/
const UniformList: React.FC<{ uniforms: Record<string, UniformDebugValue> }> = ({ uniforms }) => {
const entries = Object.entries(uniforms);
if (entries.length === 0) {
return <DetailRow label="Uniforms" value="(none)" />;
}
return (
<>
{entries.map(([name, uniform]) => (
<DetailRow
key={name}
label={name.replace(/^u_/, '')}
value={`${formatUniformValue(uniform)} (${uniform.type})`}
/>
))}
</>
);
};
/**
* 图集预览弹窗组件
* Atlas Preview Modal Component
*/
interface AtlasPreviewModalProps {
atlasStats: AtlasStats;
selectedPage: number;
onSelectPage: (page: number) => void;
onClose: () => void;
}
const AtlasPreviewModal: React.FC<AtlasPreviewModalProps> = ({
atlasStats,
selectedPage,
onSelectPage,
onClose
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [hoveredEntry, setHoveredEntry] = useState<AtlasEntryDebugInfo | null>(null);
const [loadedImages, setLoadedImages] = useState<Map<string, HTMLImageElement>>(new Map());
// 缩放和平移状态 | Zoom and pan state
const [zoom, setZoom] = useState(1);
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
const [isPanning, setIsPanning] = useState(false);
const [lastMousePos, setLastMousePos] = useState({ x: 0, y: 0 });
const currentPage = atlasStats.pages[selectedPage];
// 重置视图当页面切换时 | Reset view when page changes
useEffect(() => {
setZoom(1);
setPanOffset({ x: 0, y: 0 });
}, [selectedPage]);
// 预加载所有纹理图像 | Preload all texture images
useEffect(() => {
if (!currentPage) return;
const newImages = new Map<string, HTMLImageElement>();
let loadCount = 0;
const totalCount = currentPage.entries.filter(e => e.dataUrl).length;
currentPage.entries.forEach(entry => {
if (entry.dataUrl) {
const img = document.createElement('img');
img.onload = () => {
newImages.set(entry.guid, img);
loadCount++;
if (loadCount === totalCount) {
setLoadedImages(new Map(newImages));
}
};
img.onerror = () => {
loadCount++;
if (loadCount === totalCount) {
setLoadedImages(new Map(newImages));
}
};
img.src = entry.dataUrl;
}
});
// 如果没有图像需要加载,立即设置空 Map
if (totalCount === 0) {
setLoadedImages(new Map());
}
}, [currentPage]);
// 绘制图集预览 | Draw atlas preview
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !currentPage) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const pageSize = currentPage.width;
// 基础缩放:让图集适应画布 | Base scale: fit atlas to canvas
const baseScale = Math.min(rect.width, rect.height) / pageSize * 0.9;
// 应用用户缩放 | Apply user zoom
const scale = baseScale * zoom;
// 计算中心偏移 + 用户平移 | Calculate center offset + user pan
const offsetX = (rect.width - pageSize * scale) / 2 + panOffset.x;
const offsetY = (rect.height - pageSize * scale) / 2 + panOffset.y;
// 背景 | Background
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, rect.width, rect.height);
// 棋盘格背景(在图集区域内)| Checkerboard background (inside atlas area)
ctx.save();
ctx.beginPath();
ctx.rect(offsetX, offsetY, pageSize * scale, pageSize * scale);
ctx.clip();
const checkerSize = Math.max(8, 16 * zoom);
for (let cx = 0; cx < pageSize * scale; cx += checkerSize) {
for (let cy = 0; cy < pageSize * scale; cy += checkerSize) {
const isLight = (Math.floor(cx / checkerSize) + Math.floor(cy / checkerSize)) % 2 === 0;
ctx.fillStyle = isLight ? '#2a2a2a' : '#222';
ctx.fillRect(offsetX + cx, offsetY + cy, checkerSize, checkerSize);
}
}
ctx.restore();
// 绘制图集边框 | Draw atlas border
ctx.strokeStyle = '#444';
ctx.lineWidth = 1;
ctx.strokeRect(offsetX, offsetY, pageSize * scale, pageSize * scale);
// 绘制每个纹理区域 | Draw each texture region
const colors = ['#4a9eff', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'];
currentPage.entries.forEach((entry, idx) => {
const x = offsetX + entry.x * scale;
const y = offsetY + entry.y * scale;
const w = entry.width * scale;
const h = entry.height * scale;
const color = colors[idx % colors.length] ?? '#4a9eff';
const isHovered = hoveredEntry?.guid === entry.guid;
// 尝试绘制图像 | Try to draw image
const img = loadedImages.get(entry.guid);
if (img) {
ctx.drawImage(img, x, y, w, h);
} else {
// 没有图像时显示占位背景 | Show placeholder when no image
ctx.fillStyle = `${color}40`;
ctx.fillRect(x, y, w, h);
}
// 边框 | Border
ctx.strokeStyle = isHovered ? '#fff' : (img ? '#333' : color);
ctx.lineWidth = isHovered ? 2 : 1;
ctx.strokeRect(x, y, w, h);
// 高亮时显示尺寸标签 | Show size label when hovered
if (isHovered || (!img && w > 30 && h > 20)) {
// 半透明背景 | Semi-transparent background
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
const labelText = `${entry.width}x${entry.height}`;
ctx.font = `${Math.max(10, 10 * zoom)}px Consolas`;
const textWidth = ctx.measureText(labelText).width;
ctx.fillRect(x + w / 2 - textWidth / 2 - 4, y + h / 2 - 8, textWidth + 8, 16);
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.fillText(labelText, x + w / 2, y + h / 2 + 4);
}
});
// 绘制信息 | Draw info
ctx.fillStyle = '#666';
ctx.font = '11px system-ui';
ctx.textAlign = 'left';
ctx.fillText(`${currentPage.width}x${currentPage.height} | ${(currentPage.occupancy * 100).toFixed(1)}% | Zoom: ${(zoom * 100).toFixed(0)}%`, 8, rect.height - 8);
}, [currentPage, hoveredEntry, loadedImages, zoom, panOffset]);
// 鼠标悬停检测和拖动 | Mouse hover detection and dragging
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas || !currentPage) return;
// 处理拖动平移 | Handle pan dragging
if (isPanning) {
const dx = e.clientX - lastMousePos.x;
const dy = e.clientY - lastMousePos.y;
setPanOffset(prev => ({ x: prev.x + dx, y: prev.y + dy }));
setLastMousePos({ x: e.clientX, y: e.clientY });
return;
}
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const pageSize = currentPage.width;
const baseScale = Math.min(rect.width, rect.height) / pageSize * 0.9;
const scale = baseScale * zoom;
const offsetX = (rect.width - pageSize * scale) / 2 + panOffset.x;
const offsetY = (rect.height - pageSize * scale) / 2 + panOffset.y;
// 检查是否悬停在某个条目上 | Check if hovering over an entry
let found: AtlasEntryDebugInfo | null = null;
for (const entry of currentPage.entries) {
const x = offsetX + entry.x * scale;
const y = offsetY + entry.y * scale;
const w = entry.width * scale;
const h = entry.height * scale;
if (mouseX >= x && mouseX <= x + w && mouseY >= y && mouseY <= y + h) {
found = entry;
break;
}
}
setHoveredEntry(found);
}, [currentPage, isPanning, lastMousePos, zoom, panOffset]);
// 滚轮缩放 | Wheel zoom
const handleWheel = useCallback((e: React.WheelEvent<HTMLCanvasElement>) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
setZoom(prev => Math.max(0.5, Math.min(10, prev * delta)));
}, []);
// 开始拖动 | Start dragging
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (e.button === 0 || e.button === 1) { // 左键或中键 | Left or middle button
setIsPanning(true);
setLastMousePos({ x: e.clientX, y: e.clientY });
}
}, []);
// 结束拖动 | End dragging
const handleMouseUp = useCallback(() => {
setIsPanning(false);
}, []);
// 双击重置视图 | Double click to reset view
const handleDoubleClick = useCallback(() => {
setZoom(1);
setPanOffset({ x: 0, y: 0 });
}, []);
return (
<div className="atlas-preview-modal" onClick={onClose}>
<div className="atlas-preview-content" onClick={e => e.stopPropagation()}>
<div className="atlas-preview-header">
<span>Dynamic Atlas Preview</span>
<button className="window-btn" onClick={onClose}>
<X size={14} />
</button>
</div>
{/* 页面选择器 | Page selector */}
{atlasStats.pages.length > 1 && (
<div className="atlas-page-tabs">
{atlasStats.pages.map((page, idx) => (
<button
key={idx}
className={`atlas-page-tab ${selectedPage === idx ? 'active' : ''}`}
onClick={() => onSelectPage(idx)}
>
Page {idx} ({(page.occupancy * 100).toFixed(0)}%)
</button>
))}
</div>
)}
{/* 图集可视化 | Atlas visualization */}
<div className="atlas-preview-canvas-container">
<canvas
ref={canvasRef}
onMouseMove={handleMouseMove}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseLeave={() => { setHoveredEntry(null); setIsPanning(false); }}
onWheel={handleWheel}
onDoubleClick={handleDoubleClick}
style={{ cursor: isPanning ? 'grabbing' : 'grab' }}
/>
</div>
{/* 悬停信息 | Hover info */}
<div className="atlas-preview-info">
{hoveredEntry ? (
<>
<div className="atlas-entry-info">
<span className="label">GUID:</span>
<span className="value">{hoveredEntry.guid.slice(0, 8)}...</span>
</div>
<div className="atlas-entry-info">
<span className="label">Position:</span>
<span className="value">({hoveredEntry.x}, {hoveredEntry.y})</span>
</div>
<div className="atlas-entry-info">
<span className="label">Size:</span>
<span className="value">{hoveredEntry.width} x {hoveredEntry.height}</span>
</div>
<div className="atlas-entry-info">
<span className="label">UV:</span>
<span className="value">[{hoveredEntry.uv.map(v => v.toFixed(3)).join(', ')}]</span>
</div>
</>
) : (
<span className="hint">Scroll to zoom, drag to pan, double-click to reset</span>
)}
</div>
{/* 统计信息 | Statistics */}
<div className="atlas-preview-stats">
<span>Total: {atlasStats.textureCount} textures in {atlasStats.pageCount} page(s)</span>
<span>Avg Occupancy: {(atlasStats.averageOccupancy * 100).toFixed(1)}%</span>
{atlasStats.loadingCount > 0 && <span>Loading: {atlasStats.loadingCount}</span>}
{atlasStats.failedCount > 0 && <span className="error">Failed: {atlasStats.failedCount}</span>}
</div>
</div>
</div>
);
};
export default RenderDebugPanel;

View File

@@ -0,0 +1,87 @@
/**
* Entity Reference Field Styles
* 实体引用字段样式
*
* Uses property-field and property-label from PropertyInspector.css for consistency.
* 使用 PropertyInspector.css 中的 property-field 和 property-label 以保持一致性。
*/
/* Input container - matches property-input styling */
.entity-ref-field__input {
flex: 1;
display: flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
background: #1a1a1a;
border: 1px solid #3a3a3a;
border-radius: 2px;
gap: 4px;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.entity-ref-field__input:hover:not(.readonly) {
border-color: #4a4a4a;
}
.entity-ref-field__input.drag-over {
border-color: var(--accent-color, #4a9eff);
background: rgba(74, 158, 255, 0.1);
}
.entity-ref-field__input.readonly {
opacity: 0.7;
cursor: not-allowed;
}
/* Entity name - clickable to navigate */
.entity-ref-field__name {
flex: 1;
font-size: 11px;
font-family: 'Consolas', 'Monaco', monospace;
color: #ddd;
cursor: pointer;
padding: 2px 4px;
border-radius: 2px;
transition: background-color 0.15s ease, color 0.15s ease;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entity-ref-field__name:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--accent-color, #4a9eff);
}
/* Clear button */
.entity-ref-field__clear {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
background: transparent;
border: none;
border-radius: 2px;
color: #999;
font-size: 12px;
line-height: 1;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
flex-shrink: 0;
}
.entity-ref-field__clear:hover {
background: rgba(255, 100, 100, 0.2);
color: #ff6464;
}
/* Placeholder text */
.entity-ref-field__placeholder {
font-size: 11px;
font-family: 'Consolas', 'Monaco', monospace;
color: #666;
font-style: italic;
}

View File

@@ -0,0 +1,127 @@
/**
* Entity Reference Field
* 实体引用字段
*
* Allows drag-and-drop of entities from SceneHierarchy.
* 支持从场景层级面板拖拽实体。
*/
import React, { useCallback, useState } from 'react';
import { Core } from '@esengine/ecs-framework';
import { useHierarchyStore } from '../../../stores';
import './EntityRefField.css';
export interface EntityRefFieldProps {
/** Field label | 字段标签 */
label: string;
/** Current entity ID (0 = none) | 当前实体 ID (0 = 无) */
value: number;
/** Value change callback | 值变更回调 */
onChange: (value: number) => void;
/** Placeholder text | 占位文本 */
placeholder?: string;
/** Read-only mode | 只读模式 */
readonly?: boolean;
}
export const EntityRefField: React.FC<EntityRefFieldProps> = ({
label,
value,
onChange,
placeholder = '拖拽实体到此处 / Drop entity here',
readonly = false
}) => {
const [isDragOver, setIsDragOver] = useState(false);
// Get entity name for display
// 获取实体名称用于显示
const getEntityName = useCallback((): string | null => {
if (!value || value === 0) return null;
const scene = Core.scene;
if (!scene) return null;
const entity = scene.entities.findEntityById(value);
return entity?.name || `Entity #${value}`;
}, [value]);
const entityName = getEntityName();
const handleDragOver = useCallback((e: React.DragEvent) => {
if (readonly) return;
// Check if dragging an entity
// 检查是否拖拽实体
if (e.dataTransfer.types.includes('entity-id')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'link';
setIsDragOver(true);
}
}, [readonly]);
const handleDragLeave = useCallback(() => {
setIsDragOver(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
if (readonly) return;
e.preventDefault();
setIsDragOver(false);
const entityIdStr = e.dataTransfer.getData('entity-id');
if (entityIdStr) {
const entityId = parseInt(entityIdStr, 10);
if (!isNaN(entityId) && entityId > 0) {
onChange(entityId);
}
}
}, [readonly, onChange]);
const handleClear = useCallback(() => {
if (readonly) return;
onChange(0);
}, [readonly, onChange]);
const handleNavigateToEntity = useCallback(() => {
if (!value || value === 0) return;
// Select the referenced entity in SceneHierarchy
// 在场景层级面板中选择引用的实体
const { setSelectedIds } = useHierarchyStore.getState();
setSelectedIds(new Set([value]));
}, [value]);
return (
<div className="property-field entity-ref-field">
<label className="property-label">{label}</label>
<div
className={`entity-ref-field__input ${isDragOver ? 'drag-over' : ''} ${readonly ? 'readonly' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{entityName ? (
<>
<span
className="entity-ref-field__name"
onClick={handleNavigateToEntity}
title="点击选择此实体 / Click to select this entity"
>
{entityName}
</span>
{!readonly && (
<button
className="entity-ref-field__clear"
onClick={handleClear}
title="清除引用 / Clear reference"
>
×
</button>
)}
</>
) : (
<span className="entity-ref-field__placeholder">{placeholder}</span>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,369 @@
/**
* Material properties editor component.
* 材质属性编辑器组件。
*
* This component provides a UI for editing shader uniform values
* based on shader property metadata.
* 此组件提供基于着色器属性元数据编辑着色器 uniform 值的 UI。
*/
import React, { useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Palette } from 'lucide-react';
import type {
IMaterialOverridable,
ShaderPropertyMeta,
MaterialPropertyOverride
} from '@esengine/material-system';
import {
BuiltInShaders,
getShaderPropertiesById
} from '@esengine/material-system';
// Shader name mapping
const SHADER_NAMES: Record<number, string> = {
0: 'DefaultSprite',
1: 'Grayscale',
2: 'Tint',
3: 'Flash',
4: 'Outline',
5: 'Shiny'
};
interface MaterialPropertiesEditorProps {
/** Target component implementing IMaterialOverridable */
target: IMaterialOverridable;
/** Callback when property changes */
onChange?: (name: string, value: MaterialPropertyOverride) => void;
}
/**
* Material properties editor.
* 材质属性编辑器。
*/
export const MaterialPropertiesEditor: React.FC<MaterialPropertiesEditorProps> = ({
target,
onChange
}) => {
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['Effect', 'Default']));
const materialId = target.getMaterialId();
const shaderName = SHADER_NAMES[materialId] || `Custom(${materialId})`;
const properties = getShaderPropertiesById(materialId);
// Group properties
const groupedProps = useMemo(() => {
if (!properties) return {};
const groups: Record<string, Array<[string, ShaderPropertyMeta]>> = {};
for (const [name, meta] of Object.entries(properties)) {
if (meta.hidden) continue;
const group = meta.group || 'Default';
if (!groups[group]) groups[group] = [];
groups[group].push([name, meta]);
}
return groups;
}, [properties]);
const toggleGroup = (group: string) => {
setExpandedGroups(prev => {
const next = new Set(prev);
if (next.has(group)) {
next.delete(group);
} else {
next.add(group);
}
return next;
});
};
const handleChange = (name: string, meta: ShaderPropertyMeta, newValue: number | number[]) => {
const override: MaterialPropertyOverride = {
type: meta.type === 'texture' ? 'int' : meta.type as MaterialPropertyOverride['type'],
value: newValue
};
// Apply to target
switch (meta.type) {
case 'float':
target.setOverrideFloat(name, newValue as number);
break;
case 'int':
target.setOverrideInt(name, newValue as number);
break;
case 'vec2':
const v2 = newValue as number[];
target.setOverrideVec2(name, v2[0] ?? 0, v2[1] ?? 0);
break;
case 'vec3':
const v3 = newValue as number[];
target.setOverrideVec3(name, v3[0] ?? 0, v3[1] ?? 0, v3[2] ?? 0);
break;
case 'vec4':
const v4 = newValue as number[];
target.setOverrideVec4(name, v4[0] ?? 0, v4[1] ?? 0, v4[2] ?? 0, v4[3] ?? 0);
break;
case 'color':
const c = newValue as number[];
target.setOverrideColor(name, c[0] ?? 1, c[1] ?? 1, c[2] ?? 1, c[3] ?? 1);
break;
}
onChange?.(name, override);
};
const getCurrentValue = (name: string, meta: ShaderPropertyMeta): number | number[] => {
const override = target.getOverride(name);
if (override) {
return override.value as number | number[];
}
return meta.default as number | number[] ?? (meta.type === 'color' ? [1, 1, 1, 1] : 0);
};
// Parse i18n label
const parseLabel = (label: string): string => {
// Format: "中文 | English" - for now just return as-is
return label;
};
return (
<div className="material-properties-editor" style={{ fontSize: '12px' }}>
{/* Shader selector */}
<div style={{
display: 'flex',
alignItems: 'center',
padding: '6px 8px',
backgroundColor: '#3a3a3a',
borderRadius: '4px',
marginBottom: '8px'
}}>
<Palette size={14} style={{ marginRight: '8px', color: '#888' }} />
<span style={{ color: '#aaa', marginRight: '8px' }}>Shader:</span>
<select
value={materialId}
onChange={(e) => target.setMaterialId(Number(e.target.value))}
style={{
flex: 1,
backgroundColor: '#2a2a2a',
color: '#e0e0e0',
border: '1px solid #4a4a4a',
borderRadius: '3px',
padding: '3px 6px',
fontSize: '12px'
}}
>
<option value={0}>DefaultSprite</option>
<option value={1}>Grayscale</option>
<option value={2}>Tint</option>
<option value={3}>Flash</option>
<option value={4}>Outline</option>
<option value={5}>Shiny</option>
</select>
</div>
{/* Property groups */}
{Object.entries(groupedProps).map(([group, props]) => (
<div key={group} style={{ marginBottom: '4px' }}>
{/* Group header */}
<div
onClick={() => toggleGroup(group)}
style={{
display: 'flex',
alignItems: 'center',
padding: '4px 8px',
backgroundColor: '#333',
borderRadius: '3px',
cursor: 'pointer',
userSelect: 'none'
}}
>
{expandedGroups.has(group) ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<span style={{ marginLeft: '4px', color: '#aaa', fontWeight: 500 }}>{group}</span>
</div>
{/* Properties */}
{expandedGroups.has(group) && (
<div style={{ padding: '4px 8px' }}>
{props.map(([name, meta]) => (
<PropertyEditor
key={name}
name={name}
meta={meta}
value={getCurrentValue(name, meta)}
onChange={(v) => handleChange(name, meta, v)}
/>
))}
</div>
)}
</div>
))}
{!properties && (
<div style={{ color: '#666', padding: '8px', fontStyle: 'italic' }}>
No editable properties for {shaderName}
</div>
)}
</div>
);
};
interface PropertyEditorProps {
name: string;
meta: ShaderPropertyMeta;
value: number | number[];
onChange: (value: number | number[]) => void;
}
/**
* Individual property editor.
* 单个属性编辑器。
*/
const PropertyEditor: React.FC<PropertyEditorProps> = ({ name, meta, value, onChange }) => {
const displayName = name.replace(/^u_/, '');
const inputStyle: React.CSSProperties = {
backgroundColor: '#2a2a2a',
color: '#e0e0e0',
border: '1px solid #4a4a4a',
borderRadius: '3px',
padding: '2px 6px',
fontSize: '11px',
width: '60px'
};
const renderInput = () => {
switch (meta.type) {
case 'float':
case 'int':
return (
<input
type="number"
value={typeof value === 'number' ? value : 0}
min={meta.min}
max={meta.max}
step={meta.step ?? (meta.type === 'int' ? 1 : 0.01)}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
style={inputStyle}
/>
);
case 'vec2':
const v2 = Array.isArray(value) ? value : [0, 0];
const v2x = v2[0] ?? 0;
const v2y = v2[1] ?? 0;
return (
<div style={{ display: 'flex', gap: '4px' }}>
<input
type="number"
value={v2x}
step={meta.step ?? 0.01}
onChange={(e) => onChange([parseFloat(e.target.value) || 0, v2y])}
style={{ ...inputStyle, width: '50px' }}
/>
<input
type="number"
value={v2y}
step={meta.step ?? 0.01}
onChange={(e) => onChange([v2x, parseFloat(e.target.value) || 0])}
style={{ ...inputStyle, width: '50px' }}
/>
</div>
);
case 'vec3':
const v3 = Array.isArray(value) ? value : [0, 0, 0];
return (
<div style={{ display: 'flex', gap: '4px' }}>
{[0, 1, 2].map(i => (
<input
key={i}
type="number"
value={v3[i]}
step={meta.step ?? 0.01}
onChange={(e) => {
const newVal = [...v3];
newVal[i] = parseFloat(e.target.value) || 0;
onChange(newVal);
}}
style={{ ...inputStyle, width: '40px' }}
/>
))}
</div>
);
case 'vec4':
const v4 = Array.isArray(value) ? value : [0, 0, 0, 0];
return (
<div style={{ display: 'flex', gap: '4px' }}>
{[0, 1, 2, 3].map(i => (
<input
key={i}
type="number"
value={v4[i]}
step={meta.step ?? 0.01}
onChange={(e) => {
const newVal = [...v4];
newVal[i] = parseFloat(e.target.value) || 0;
onChange(newVal);
}}
style={{ ...inputStyle, width: '35px' }}
/>
))}
</div>
);
case 'color':
const c = Array.isArray(value) ? value : [1, 1, 1, 1];
const cr = c[0] ?? 1;
const cg = c[1] ?? 1;
const cb = c[2] ?? 1;
const ca = c[3] ?? 1;
const hexColor = `#${Math.round(cr * 255).toString(16).padStart(2, '0')}${Math.round(cg * 255).toString(16).padStart(2, '0')}${Math.round(cb * 255).toString(16).padStart(2, '0')}`;
return (
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
<input
type="color"
value={hexColor}
onChange={(e) => {
const hex = e.target.value;
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
onChange([r, g, b, ca]);
}}
style={{ width: '24px', height: '20px', padding: 0, border: 'none' }}
/>
<input
type="number"
value={ca}
min={0}
max={1}
step={0.01}
onChange={(e) => onChange([cr, cg, cb, parseFloat(e.target.value) || 1])}
style={{ ...inputStyle, width: '40px' }}
title="Alpha"
/>
</div>
);
default:
return <span style={{ color: '#666' }}>Unsupported type: {meta.type}</span>;
}
};
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '3px 0',
borderBottom: '1px solid #333'
}}>
<span style={{ color: '#aaa' }} title={meta.tooltip}>
{displayName}
</span>
{renderInput()}
</div>
);
};
export default MaterialPropertiesEditor;

View File

@@ -0,0 +1,6 @@
/**
* Material Inspector components.
* 材质 Inspector 组件。
*/
export { MaterialPropertiesEditor } from './MaterialPropertiesEditor';

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { Folder, File as FileIcon, Image as ImageIcon, Clock, HardDrive, Settings2, Grid3X3 } from 'lucide-react';
import { convertFileSrc } from '@tauri-apps/api/core';
import { Core } from '@esengine/ecs-framework';
import { AssetRegistryService } from '@esengine/editor-core';
import { AssetRegistryService, MessageHub } from '@esengine/editor-core';
import type { ISpriteSettings } from '@esengine/asset-system-editor';
import { EngineService } from '../../../services/EngineService';
import { AssetFileInfo } from '../types';
@@ -315,6 +315,18 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
setSpriteSettings(newSettings);
console.log(`[AssetFileInspector] Updated sprite settings for ${fileInfo.name}:`, newSettings);
// 通知 EngineService 同步资产数据库(以便渲染系统获取最新的九宫格设置)
// Notify EngineService to sync asset database (so render systems get latest sprite settings)
const messageHub = Core.services.tryResolve(MessageHub);
if (messageHub) {
messageHub.publish('assets:changed', {
type: 'modify',
path: fileInfo.path,
relativePath: assetRegistry.absoluteToRelative(fileInfo.path) || fileInfo.path,
guid: meta.guid
});
}
} catch (error) {
console.error('Failed to update sprite settings:', error);
} finally {

View File

@@ -21,6 +21,8 @@ interface UseStoreSubscriptionsOptions {
entityStore: EntityStoreService | null;
sceneManager: SceneManagerService | null;
enabled: boolean;
/** 是否处于 Play 模式 | Whether in play mode */
isPlaying?: boolean;
}
/**
@@ -35,8 +37,10 @@ export function useStoreSubscriptions({
entityStore,
sceneManager,
enabled,
isPlaying = false,
}: UseStoreSubscriptionsOptions): void {
const initializedRef = useRef(false);
const lastEntityCountRef = useRef(0);
// ===== HierarchyStore 订阅 | HierarchyStore subscriptions =====
useEffect(() => {
@@ -68,9 +72,38 @@ export function useStoreSubscriptions({
};
// 处理实体选择 | Handle entity selection
// Also expand parent nodes so selected entity is visible
// 同时展开父节点以便选中的实体可见
const handleEntitySelection = (data: { entity: { id: number } | null }) => {
if (data.entity) {
setSelectedIds(new Set([data.entity.id]));
// Expand all ancestor nodes | 展开所有祖先节点
const scene = Core.scene;
if (scene) {
const entity = scene.entities.findEntityById(data.entity.id);
if (entity) {
const ancestorIds: number[] = [];
// Use HierarchyComponent to get parent chain
// 使用 HierarchyComponent 获取父节点链
let currentEntity = entity;
let hierarchy = currentEntity.getComponent(HierarchyComponent);
while (hierarchy?.parentId != null) {
ancestorIds.push(hierarchy.parentId);
const parentEntity = scene.entities.findEntityById(hierarchy.parentId);
if (!parentEntity) break;
currentEntity = parentEntity;
hierarchy = currentEntity.getComponent(HierarchyComponent);
}
if (ancestorIds.length > 0) {
setExpandedIds((prev) => {
const next = new Set(prev);
ancestorIds.forEach(id => next.add(id));
return next;
});
}
}
}
} else {
setSelectedIds(new Set());
}
@@ -129,7 +162,25 @@ export function useStoreSubscriptions({
});
const unsubSaved = messageHub.subscribe('scene:saved', updateSceneInfo);
const unsubModified = messageHub.subscribe('scene:modified', updateSceneInfo);
const unsubRestored = messageHub.subscribe('scene:restored', updateEntities);
// scene:restored 在 Stop 时触发,需要同时更新场景信息和实体列表
// scene:restored is triggered on Stop, needs to update both scene info and entities
const unsubRestored = messageHub.subscribe('scene:restored', () => {
updateSceneInfo();
updateEntities();
});
// 订阅运行时场景切换事件Play 模式下的场景切换)
// Subscribe to runtime scene change event (scene switching in Play mode)
const unsubRuntimeSceneChanged = messageHub.subscribe('runtime:scene:changed', (data: any) => {
if (data.sceneName) {
setSceneInfo({
sceneName: `[Play] ${data.sceneName}`,
sceneFilePath: data.path || null,
isModified: false,
});
}
updateEntities();
});
// 订阅实体事件 | Subscribe to entity events
const unsubAdd = messageHub.subscribe('entity:added', updateEntities);
@@ -150,6 +201,7 @@ export function useStoreSubscriptions({
unsubSaved();
unsubModified();
unsubRestored();
unsubRuntimeSceneChanged();
unsubAdd();
unsubRemove();
unsubClear();
@@ -348,4 +400,43 @@ export function useStoreSubscriptions({
unsubPropertyChanged();
};
}, [enabled, messageHub]);
// ===== Play 模式实时同步 | Play mode real-time sync =====
// 在 Play 模式下定期检查场景实体变化,同步到层级面板
// Periodically check scene entity changes in play mode and sync to hierarchy panel
useEffect(() => {
if (!enabled || !entityStore || !isPlaying) return;
const { setEntities } = useHierarchyStore.getState();
// 同步实体列表(检查是否有变化)
// Sync entity list (check for changes)
const syncEntities = () => {
const scene = Core.scene;
if (!scene) return;
const currentCount = scene.entities.count;
// 只有实体数量变化时才同步(性能优化)
// Only sync when entity count changes (performance optimization)
if (currentCount !== lastEntityCountRef.current) {
lastEntityCountRef.current = currentCount;
entityStore.syncFromScene();
setEntities([...entityStore.getRootEntities()]);
}
};
// 每 500ms 检查一次Play 模式下足够实时)
// Check every 500ms (real-time enough for play mode)
const intervalId = setInterval(syncEntities, 500);
// 立即同步一次
// Sync immediately
syncEntities();
return () => {
clearInterval(intervalId);
lastEntityCountRef.current = 0;
};
}, [enabled, entityStore, isPlaying]);
}

View File

@@ -1,20 +0,0 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import zh from './locales/zh.json';
import en from './locales/en.json';
i18n
.use(initReactI18next)
.init({
resources: {
zh: { translation: zh },
en: { translation: en }
},
lng: 'zh',
fallbackLng: 'en',
interpolation: {
escapeValue: false
}
});
export default i18n;

View File

@@ -1,102 +0,0 @@
{
"hierarchy": {
"visibility": "Toggle Visibility",
"hideEntity": "Hide Entity",
"showEntity": "Show Entity",
"emptyHint": "No entities in scene"
},
"behaviorTree": {
"title": "Behavior Tree Editor",
"close": "Close",
"nodePalette": "Node Palette",
"properties": "Properties",
"blackboard": "Blackboard",
"noNodeSelected": "No node selected",
"noConfigurableProperties": "This node has no configurable properties",
"apply": "Apply",
"reset": "Reset",
"addVariable": "Add Variable",
"variableName": "Variable Name",
"type": "Type",
"value": "Value",
"defaultGroup": "Default Group",
"rootNode": "Root Node",
"rootNodeOnlyOneChild": "Root node can only connect to one child",
"dragToCreate": "Drag nodes from the left to below the root node to start creating behavior tree",
"connectFirst": "Connect the root node with the first node first",
"nodeCount": "Nodes",
"noSelection": "No selection",
"selectedCount": "{{count}} nodes selected",
"idle": "Idle",
"running": "Running",
"paused": "Paused",
"step": "Step",
"run": "Run",
"pause": "Pause",
"resume": "Resume",
"stop": "Stop",
"stepExecution": "Step Execution",
"resetExecution": "Reset",
"clear": "Clear",
"resetView": "Reset View",
"tick": "Tick",
"executing": "Executing",
"success": "Success",
"failure": "Failure",
"startingExecution": "Starting execution from root...",
"tickNumber": "Tick {{tick}}",
"executionStopped": "Execution stopped after {{tick}} ticks",
"executionPaused": "Execution paused",
"executionResumed": "Execution resumed",
"resetToInitial": "Reset to initial state",
"currentValue": "Current Value"
},
"components": {
"category": {
"core": "Core",
"rendering": "Rendering",
"physics": "Physics",
"audio": "Audio",
"tilemap": "Tilemap"
},
"material": {
"name": "Material",
"description": "Custom material and shader component"
},
"transform": {
"description": "Transform - Position, Rotation, Scale"
},
"sprite": {
"description": "Sprite - 2D Image Rendering"
},
"text": {
"description": "Text - Text Rendering"
},
"camera": {
"description": "Camera - View Control"
},
"rigidBody": {
"description": "RigidBody - Physics Simulation"
},
"boxCollider": {
"description": "Box Collider"
},
"circleCollider": {
"description": "Circle Collider"
},
"audioSource": {
"description": "Audio Source"
}
},
"file": {
"create": {
"material": "Material",
"shader": "Shader"
}
},
"entity": {
"create": {
"materialEntity": "Material Entity"
}
}
}

View File

@@ -1,102 +0,0 @@
{
"hierarchy": {
"visibility": "切换可见性",
"hideEntity": "隐藏实体",
"showEntity": "显示实体",
"emptyHint": "场景中没有实体"
},
"behaviorTree": {
"title": "行为树编辑器",
"close": "关闭",
"nodePalette": "节点面板",
"properties": "属性",
"blackboard": "黑板",
"noNodeSelected": "未选择节点",
"noConfigurableProperties": "此节点没有可配置的属性",
"apply": "应用",
"reset": "重置",
"addVariable": "添加变量",
"variableName": "变量名",
"type": "类型",
"value": "值",
"defaultGroup": "默认分组",
"rootNode": "根节点",
"rootNodeOnlyOneChild": "根节点只能连接一个子节点",
"dragToCreate": "从左侧拖拽节点到根节点下方开始创建行为树",
"connectFirst": "先连接根节点与第一个节点",
"nodeCount": "节点数",
"noSelection": "未选择节点",
"selectedCount": "已选择 {{count}} 个节点",
"idle": "空闲",
"running": "运行中",
"paused": "已暂停",
"step": "单步",
"run": "运行",
"pause": "暂停",
"resume": "继续",
"stop": "停止",
"stepExecution": "单步执行",
"resetExecution": "重置",
"clear": "清空",
"resetView": "重置视图",
"tick": "帧",
"executing": "执行中",
"success": "成功",
"failure": "失败",
"startingExecution": "从根节点开始执行...",
"tickNumber": "第 {{tick}} 帧",
"executionStopped": "执行停止,共 {{tick}} 帧",
"executionPaused": "执行已暂停",
"executionResumed": "执行已恢复",
"resetToInitial": "重置到初始状态",
"currentValue": "当前值"
},
"components": {
"category": {
"core": "基础",
"rendering": "渲染",
"physics": "物理",
"audio": "音频",
"tilemap": "瓦片地图"
},
"material": {
"name": "材质",
"description": "自定义材质和着色器组件"
},
"transform": {
"description": "变换组件 - 位置、旋转、缩放"
},
"sprite": {
"description": "精灵组件 - 2D图像渲染"
},
"text": {
"description": "文本组件 - 文本渲染"
},
"camera": {
"description": "相机组件 - 视图控制"
},
"rigidBody": {
"description": "刚体组件 - 物理模拟"
},
"boxCollider": {
"description": "盒型碰撞器"
},
"circleCollider": {
"description": "圆形碰撞器"
},
"audioSource": {
"description": "音频源组件"
}
},
"file": {
"create": {
"material": "材质",
"shader": "着色器"
}
},
"entity": {
"create": {
"materialEntity": "材质实体"
}
}
}

View File

@@ -0,0 +1,60 @@
/**
* Entity Reference Field Editor
* 实体引用字段编辑器
*
* Handles editing of entity reference fields with drag-and-drop support.
* 处理实体引用字段的编辑,支持拖放操作。
*/
import React from 'react';
import { IFieldEditor, FieldEditorProps } from '@esengine/editor-core';
import { EntityRefField } from '../../components/inspectors/fields/EntityRefField';
/**
* Field editor for entity references (entity IDs)
* 实体引用(实体 ID的字段编辑器
*
* Supports:
* - Drag-and-drop entities from SceneHierarchy
* - Click to navigate to referenced entity
* - Clear button to remove reference
*
* 支持:
* - 从场景层级面板拖放实体
* - 点击导航到引用的实体
* - 清除按钮移除引用
*/
export class EntityRefFieldEditor implements IFieldEditor<number> {
readonly type = 'entityRef';
readonly name = 'Entity Reference Field Editor';
readonly priority = 100;
/**
* Check if this editor can handle the given field type
* 检查此编辑器是否可以处理给定的字段类型
*/
canHandle(fieldType: string): boolean {
return fieldType === 'entityRef' ||
fieldType === 'entityReference' ||
fieldType === 'EntityRef' ||
fieldType.endsWith('EntityId');
}
/**
* Render the entity reference field
* 渲染实体引用字段
*/
render({ label, value, onChange, context }: FieldEditorProps<number>): React.ReactElement {
const placeholder = context.metadata?.placeholder || '拖拽实体到此处 / Drop entity here';
return (
<EntityRefField
label={label}
value={value ?? 0}
onChange={onChange}
placeholder={placeholder}
readonly={context.readonly}
/>
);
}
}

View File

@@ -2,3 +2,4 @@ export * from './AssetFieldEditor';
export * from './VectorFieldEditors';
export * from './ColorFieldEditor';
export * from './AnimationClipsFieldEditor';
export * from './EntityRefFieldEditor';

View File

@@ -1223,6 +1223,32 @@ export const en: Translations = {
label: 'Module List',
description: 'Uncheck modules you do not need. Core modules cannot be disabled. New modules are enabled by default.'
}
},
dynamicAtlas: {
title: 'Dynamic Atlas',
description: 'Runtime atlas configuration for UI batching optimization',
enabled: {
label: 'Enable Dynamic Atlas',
description: 'Enable runtime dynamic atlas to reduce Draw Calls'
},
expansionStrategy: {
label: 'Expansion Strategy',
description: 'Choose how the atlas expands',
fixed: 'Fixed Size (No rebuild cost)',
dynamic: 'Dynamic Expansion (Better memory efficiency)'
},
fixedPageSize: {
label: 'Page Size',
description: 'Size of each atlas page in fixed mode'
},
maxPages: {
label: 'Max Pages',
description: 'Maximum number of atlas pages allowed'
},
maxTextureSize: {
label: 'Max Texture Size',
description: 'Maximum size of individual textures that can be added to the atlas'
}
}
}
}

View File

@@ -1139,6 +1139,32 @@ export const es: Translations = {
label: 'Lista de Módulos',
description: 'Desmarcar módulos que no necesitas. Los módulos principales no se pueden deshabilitar. Los nuevos módulos se habilitan por defecto.'
}
},
dynamicAtlas: {
title: 'Atlas Dinámico',
description: 'Configuración de atlas en tiempo de ejecución para optimización de batching de UI',
enabled: {
label: 'Habilitar Atlas Dinámico',
description: 'Habilitar atlas dinámico en tiempo de ejecución para reducir Draw Calls'
},
expansionStrategy: {
label: 'Estrategia de Expansión',
description: 'Elegir cómo se expande el atlas',
fixed: 'Tamaño Fijo (Sin costo de reconstrucción)',
dynamic: 'Expansión Dinámica (Mejor eficiencia de memoria)'
},
fixedPageSize: {
label: 'Tamaño de Página',
description: 'Tamaño de cada página del atlas en modo fijo'
},
maxPages: {
label: 'Páginas Máximas',
description: 'Número máximo de páginas de atlas permitidas'
},
maxTextureSize: {
label: 'Tamaño Máximo de Textura',
description: 'Tamaño máximo de texturas individuales que pueden añadirse al atlas'
}
}
}
}

View File

@@ -1223,6 +1223,32 @@ export const zh: Translations = {
label: '模块列表',
description: '取消勾选不需要的模块。核心模块不能禁用。新增的模块会自动启用。'
}
},
dynamicAtlas: {
title: '动态图集',
description: '运行时图集配置,用于 UI 合批优化',
enabled: {
label: '启用动态图集',
description: '启用运行时动态图集以减少 Draw Call'
},
expansionStrategy: {
label: '扩展策略',
description: '选择图集的扩展方式',
fixed: '固定大小(无重建开销)',
dynamic: '动态扩展(内存效率更高)'
},
fixedPageSize: {
label: '页面大小',
description: '固定模式下每个图集页面的大小'
},
maxPages: {
label: '最大页数',
description: '允许的最大图集页面数量'
},
maxTextureSize: {
label: '最大纹理尺寸',
description: '可加入图集的最大单个纹理尺寸,超过此尺寸的纹理将不会被合批'
}
}
}
}

View File

@@ -6,7 +6,6 @@ import { invoke } from '@tauri-apps/api/core';
import App from './App';
import './styles/global.css';
import './styles/index.css';
import './i18n/config';
// Set log level to Warn in production to reduce console noise
setGlobalLogLevel(LogLevel.Warn);

View File

@@ -136,6 +136,74 @@ class ProjectSettingsEditorModule implements IEditorModuleLoader {
}
} as any // Cast to any to allow custom props
]
},
{
id: 'dynamic-atlas',
title: '$pluginSettings.project.dynamicAtlas.title',
description: '$pluginSettings.project.dynamicAtlas.description',
settings: [
{
key: 'project.dynamicAtlas.enabled',
label: '$pluginSettings.project.dynamicAtlas.enabled.label',
type: 'boolean',
defaultValue: true,
description: '$pluginSettings.project.dynamicAtlas.enabled.description'
},
{
key: 'project.dynamicAtlas.expansionStrategy',
label: '$pluginSettings.project.dynamicAtlas.expansionStrategy.label',
type: 'select',
defaultValue: 'fixed',
description: '$pluginSettings.project.dynamicAtlas.expansionStrategy.description',
options: [
{
label: '$pluginSettings.project.dynamicAtlas.expansionStrategy.fixed',
value: 'fixed'
},
{
label: '$pluginSettings.project.dynamicAtlas.expansionStrategy.dynamic',
value: 'dynamic'
}
]
},
{
key: 'project.dynamicAtlas.fixedPageSize',
label: '$pluginSettings.project.dynamicAtlas.fixedPageSize.label',
type: 'select',
defaultValue: 1024,
description: '$pluginSettings.project.dynamicAtlas.fixedPageSize.description',
options: [
{ label: '512 x 512', value: 512 },
{ label: '1024 x 1024', value: 1024 },
{ label: '2048 x 2048', value: 2048 }
]
},
{
key: 'project.dynamicAtlas.maxPages',
label: '$pluginSettings.project.dynamicAtlas.maxPages.label',
type: 'select',
defaultValue: 4,
description: '$pluginSettings.project.dynamicAtlas.maxPages.description',
options: [
{ label: '1', value: 1 },
{ label: '2', value: 2 },
{ label: '4', value: 4 },
{ label: '8', value: 8 }
]
},
{
key: 'project.dynamicAtlas.maxTextureSize',
label: '$pluginSettings.project.dynamicAtlas.maxTextureSize.label',
type: 'select',
defaultValue: 512,
description: '$pluginSettings.project.dynamicAtlas.maxTextureSize.description',
options: [
{ label: '256 x 256', value: 256 },
{ label: '512 x 512', value: 512 },
{ label: '1024 x 1024', value: 1024 }
]
}
]
}
]
});
@@ -172,11 +240,35 @@ class ProjectSettingsEditorModule implements IEditorModuleLoader {
logger.info('UI design resolution changed, applying...');
this.applyUIDesignResolution();
}
// Check if dynamic atlas settings changed
// 检查动态图集设置是否更改
if ('project.dynamicAtlas.enabled' in changedSettings ||
'project.dynamicAtlas.expansionStrategy' in changedSettings ||
'project.dynamicAtlas.fixedPageSize' in changedSettings ||
'project.dynamicAtlas.maxPages' in changedSettings ||
'project.dynamicAtlas.maxTextureSize' in changedSettings) {
logger.info('Dynamic atlas settings changed, reinitializing...');
this.applyDynamicAtlasSettings();
}
}) as EventListener;
window.addEventListener('settings:changed', this.settingsListener);
}
/**
* Apply dynamic atlas settings
* 应用动态图集设置
*/
private applyDynamicAtlasSettings(): void {
const engineService = EngineService.getInstance();
if (engineService.isInitialized()) {
engineService.reinitializeDynamicAtlas();
logger.info('Dynamic atlas settings applied');
}
}
/**
* Apply UI design resolution from ProjectService
* 从 ProjectService 应用 UI 设计分辨率

View File

@@ -0,0 +1,149 @@
/**
* Editor Asset File Loader
* 编辑器资产文件加载器
*
* Platform-specific implementation of IAssetFileLoader for Tauri editor.
* Combines path resolution with TauriAssetReader for unified asset loading.
* Tauri 编辑器的 IAssetFileLoader 平台特定实现。
* 结合路径解析和 TauriAssetReader 实现统一的资产加载。
*/
import type { IAssetFileLoader, IAssetReader } from '@esengine/asset-system';
/**
* Configuration for EditorAssetFileLoader.
* EditorAssetFileLoader 配置。
*/
export interface EditorAssetFileLoaderConfig {
/**
* Function to get current project path.
* 获取当前项目路径的函数。
*/
getProjectPath: () => string | null;
}
/**
* Editor asset file loader implementation.
* 编辑器资产文件加载器实现。
*
* This loader combines:
* - Path resolution: converts relative asset paths to absolute paths
* - Platform reading: uses IAssetReader (TauriAssetReader) for actual file loading
*
* 此加载器结合:
* - 路径解析:将相对资产路径转换为绝对路径
* - 平台读取:使用 IAssetReader (TauriAssetReader) 进行实际文件加载
*
* @example
* ```typescript
* const loader = new EditorAssetFileLoader(assetReader, {
* getProjectPath: () => projectService.getCurrentProject()?.path
* });
*
* // Load from relative asset path
* const image = await loader.loadImage('assets/demo/button.png');
* ```
*/
export class EditorAssetFileLoader implements IAssetFileLoader {
/**
* Create a new editor asset file loader.
* 创建新的编辑器资产文件加载器。
*
* @param assetReader - Platform-specific asset reader (e.g., TauriAssetReader).
* 平台特定的资产读取器。
* @param config - Loader configuration. | 加载器配置。
*/
constructor(
private readonly assetReader: IAssetReader,
private readonly config: EditorAssetFileLoaderConfig
) {}
/**
* Load image from asset path.
* 从资产路径加载图片。
*/
async loadImage(assetPath: string): Promise<HTMLImageElement> {
const absolutePath = this.resolveToAbsolutePath(assetPath);
return this.assetReader.loadImage(absolutePath);
}
/**
* Load text content from asset path.
* 从资产路径加载文本内容。
*/
async loadText(assetPath: string): Promise<string> {
const absolutePath = this.resolveToAbsolutePath(assetPath);
return this.assetReader.readText(absolutePath);
}
/**
* Load binary data from asset path.
* 从资产路径加载二进制数据。
*/
async loadBinary(assetPath: string): Promise<ArrayBuffer> {
const absolutePath = this.resolveToAbsolutePath(assetPath);
return this.assetReader.readBinary(absolutePath);
}
/**
* Check if asset file exists.
* 检查资产文件是否存在。
*/
async exists(assetPath: string): Promise<boolean> {
const absolutePath = this.resolveToAbsolutePath(assetPath);
return this.assetReader.exists(absolutePath);
}
/**
* Resolve relative asset path to absolute file system path.
* 将相对资产路径解析为绝对文件系统路径。
*
* @param assetPath - Relative asset path (e.g., "assets/demo/button.png").
* 相对资产路径。
* @returns Absolute file system path. | 绝对文件系统路径。
*/
private resolveToAbsolutePath(assetPath: string): string {
// Already an absolute path or URL - return as-is
// 已经是绝对路径或 URL - 直接返回
if (this.isAbsoluteOrUrl(assetPath)) {
return assetPath;
}
// Get project path and combine with asset path
// 获取项目路径并与资产路径组合
const projectPath = this.config.getProjectPath();
if (!projectPath) {
// No project open, return original path
// 没有打开项目,返回原始路径
console.warn('[EditorAssetFileLoader] No project open, cannot resolve path:', assetPath);
return assetPath;
}
// Determine separator based on project path format
// 根据项目路径格式确定分隔符
const separator = projectPath.includes('\\') ? '\\' : '/';
// Normalize asset path separators to match project path
// 规范化资产路径分隔符以匹配项目路径
const normalizedAssetPath = assetPath.replace(/\//g, separator);
// Combine paths
// 组合路径
return `${projectPath}${separator}${normalizedAssetPath}`;
}
/**
* Check if path is already absolute or a URL.
* 检查路径是否已经是绝对路径或 URL。
*/
private isAbsoluteOrUrl(path: string): boolean {
return (
path.startsWith('http://') ||
path.startsWith('https://') ||
path.startsWith('data:') ||
path.startsWith('asset://') ||
path.startsWith('/') ||
/^[a-zA-Z]:/.test(path) // Windows absolute path (e.g., "C:\...")
);
}
}

View File

@@ -6,13 +6,24 @@
* Uses the unified GameRuntime architecture
*/
import { GizmoRegistry, EntityStoreService, MessageHub, SceneManagerService, ProjectService, PluginManager, IPluginManager, AssetRegistryService, type SystemContext } from '@esengine/editor-core';
import { GizmoRegistry, EntityStoreService, MessageHub, SceneManagerService, ProjectService, PluginManager, IPluginManager, AssetRegistryService, GizmoInteractionService, GizmoInteractionServiceToken, type SystemContext } from '@esengine/editor-core';
import { Core, Scene, Entity, SceneSerializer, ProfilerSDK, createLogger, PluginServiceRegistry } from '@esengine/ecs-framework';
import { CameraConfig, EngineBridgeToken, RenderSystemToken, EngineIntegrationToken } from '@esengine/ecs-engine-bindgen';
import { TransformComponent, TransformTypeToken, CanvasElementToken } from '@esengine/engine-core';
import { SpriteComponent, SpriteAnimatorComponent, SpriteAnimatorSystemToken } from '@esengine/sprite';
import { ParticleSystemComponent } from '@esengine/particle';
import { invalidateUIRenderCaches, UIRenderProviderToken, UIInputSystemToken } from '@esengine/ui';
import {
invalidateUIRenderCaches,
UIRenderProviderToken,
UIInputSystemToken,
initializeDynamicAtlasService,
reinitializeDynamicAtlasService,
registerTexturePathMapping,
AtlasExpansionStrategy,
type IAtlasEngineBridge,
type DynamicAtlasConfig
} from '@esengine/ui';
import { SettingsService } from './SettingsService';
import * as esEngine from '@esengine/engine';
import {
AssetManager,
@@ -22,8 +33,12 @@ import {
SceneResourceManager,
AssetType,
AssetManagerToken,
isValidGUID
isValidGUID,
setGlobalAssetDatabase,
setGlobalEngineBridge,
setGlobalAssetFileLoader
} from '@esengine/asset-system';
import { EditorAssetFileLoader } from './EditorAssetFileLoader';
import {
GameRuntime,
createGameRuntime,
@@ -56,6 +71,7 @@ export class EngineService {
private _modulesInitialized = false;
private _running = false;
private _canvasId: string | null = null;
private _gizmoInteractionService: GizmoInteractionService | null = null;
// 资产系统相关
private _assetManager: AssetManager | null = null;
@@ -68,6 +84,9 @@ export class EngineService {
// 编辑器相机状态(用于恢复)
private _editorCameraState = { x: 0, y: 0, zoom: 1 };
// 当前选中的实体 IDs用于高亮| Currently selected entity IDs (for highlighting)
private _selectedEntityIds: number[] = [];
private constructor() {}
/**
@@ -146,6 +165,13 @@ export class EngineService {
await this._runtime.initialize();
// 设置 MaterialManager 的引擎桥接(上传内置 shader 到 GPU
// Set engine bridge for MaterialManager (upload built-in shaders to GPU)
const materialManager = getMaterialManager();
if (materialManager && this._runtime.bridge) {
materialManager.setEngineBridge(this._runtime.bridge);
}
// 启用性能分析器(编辑器模式默认启用)
ProfilerSDK.setEnabled(true);
@@ -157,6 +183,21 @@ export class EngineService {
GizmoRegistry.hasProvider(component.constructor as any)
);
// 初始化 Gizmo 交互服务
// Initialize Gizmo Interaction Service
this._gizmoInteractionService = new GizmoInteractionService();
Core.pluginServices.register(GizmoInteractionServiceToken, this._gizmoInteractionService);
// 设置 Gizmo 交互函数到渲染系统
// Set gizmo interaction functions to render system
if (this._runtime.renderSystem) {
this._runtime.renderSystem.setGizmoInteraction(
(entityId: number, baseColor: { r: number; g: number; b: number; a: number }, isSelected: boolean) =>
this._gizmoInteractionService!.getHighlightColor(entityId, baseColor, isSelected),
() => this._gizmoInteractionService!.getHoveredEntityId()
);
}
// 初始化资产系统
await this._initializeAssetSystem();
@@ -437,6 +478,22 @@ export class EngineService {
// 将 AssetRegistryService 的数据同步到 assetManager 的数据库
await this._syncAssetRegistryToManager();
// 设置全局资产数据库(供渲染系统查询 sprite 元数据)
// Set global asset database (for render systems to query sprite metadata)
setGlobalAssetDatabase(this._assetManager.getDatabase());
// 设置全局资产文件加载器(供动态图集服务等使用)
// Set global asset file loader (for DynamicAtlasService etc.)
const editorAssetFileLoader = new EditorAssetFileLoader(assetReader, {
getProjectPath: () => {
if (projectService && projectService.isProjectOpen()) {
return projectService.getCurrentProject()?.path ?? null;
}
return null;
}
});
setGlobalAssetFileLoader(editorAssetFileLoader);
const pathTransformerFn = (path: string) => {
if (!path.startsWith('http://') && !path.startsWith('https://') &&
!path.startsWith('data:') && !path.startsWith('asset://')) {
@@ -461,6 +518,33 @@ export class EngineService {
});
if (this._runtime?.bridge) {
// 为 EngineBridge 设置路径解析器(用于 getTextureInfoByPath 等方法)
// Set path resolver for EngineBridge (for getTextureInfoByPath etc.)
this._runtime.bridge.setPathResolver((assetPath: string) => {
// 空路径直接返回
if (!assetPath) return assetPath;
// 已经是 URL 则直接返回
if (assetPath.startsWith('http://') ||
assetPath.startsWith('https://') ||
assetPath.startsWith('data:') ||
assetPath.startsWith('asset://')) {
return assetPath;
}
// 使用 pathTransformerFn 转换路径为 Tauri URL
let fullPath = assetPath;
// 如果路径不以 'assets/' 开头,添加前缀
if (!assetPath.startsWith('assets/') && !assetPath.startsWith('assets\\')) {
fullPath = `assets/${assetPath}`;
}
return pathTransformerFn(fullPath);
});
// 设置全局引擎桥(供渲染系统查询纹理尺寸 - 唯一事实来源)
// Set global engine bridge (for render systems to query texture dimensions - single source of truth)
setGlobalEngineBridge(this._runtime.bridge);
this._engineIntegration = new EngineIntegration(this._assetManager, this._runtime.bridge);
// 为 EngineIntegration 设置使用 Tauri URL 转换的 PathResolver
@@ -503,6 +587,58 @@ export class EngineService {
this._sceneResourceManager = new SceneResourceManager();
this._sceneResourceManager.setResourceLoader(this._engineIntegration);
// 初始化动态图集服务(用于 UI 合批)
// Initialize dynamic atlas service (for UI batching)
const bridge = this._runtime.bridge;
if (bridge.createBlankTexture && bridge.updateTextureRegion) {
const atlasBridge: IAtlasEngineBridge = {
createBlankTexture: (width: number, height: number) => {
return bridge.createBlankTexture(width, height);
},
updateTextureRegion: (
id: number,
x: number,
y: number,
width: number,
height: number,
pixels: Uint8Array
) => {
bridge.updateTextureRegion(id, x, y, width, height, pixels);
}
};
// 从设置中获取动态图集配置
// Get dynamic atlas config from settings
const settingsService = SettingsService.getInstance();
const atlasEnabled = settingsService.get('project.dynamicAtlas.enabled', true);
if (atlasEnabled) {
const strategyValue = settingsService.get<string>('project.dynamicAtlas.expansionStrategy', 'fixed');
const expansionStrategy = strategyValue === 'dynamic'
? AtlasExpansionStrategy.Dynamic
: AtlasExpansionStrategy.Fixed;
const fixedPageSize = settingsService.get('project.dynamicAtlas.fixedPageSize', 1024);
const maxPages = settingsService.get('project.dynamicAtlas.maxPages', 4);
const maxTextureSize = settingsService.get('project.dynamicAtlas.maxTextureSize', 512);
initializeDynamicAtlasService(atlasBridge, {
expansionStrategy,
initialPageSize: 256, // 动态模式起始大小 | Dynamic mode initial size
fixedPageSize, // 固定模式页面大小 | Fixed mode page size
maxPageSize: 2048, // 最大页面大小 | Max page size
maxPages,
maxTextureSize,
padding: 1
});
}
// 注册纹理加载回调,当纹理加载时自动注册路径映射
// Register texture load callback to register path mapping when textures load
EngineIntegration.onTextureLoad((guid: string, path: string, _textureId: number) => {
registerTexturePathMapping(guid, path);
});
}
const sceneManagerService = Core.services.tryResolve<SceneManagerService>(SceneManagerService);
if (sceneManagerService) {
sceneManagerService.setSceneResourceManager(this._sceneResourceManager);
@@ -570,6 +706,13 @@ export class EngineService {
// 1. Check for explicit loaderType in .meta file (user override)
// 1. 检查 .meta 文件中的显式 loaderType用户覆盖
const meta = metaManager.getMetaByGUID(asset.guid);
// Debug: log meta for textures with importSettings
// 调试:记录有 importSettings 的纹理 meta
if (meta?.importSettings?.spriteSettings) {
console.log(`[EngineService] Syncing asset with spriteSettings: ${asset.path}`, meta.importSettings.spriteSettings);
}
if (meta?.loaderType) {
assetType = meta.loaderType;
}
@@ -607,10 +750,13 @@ export class EngineService {
size: asset.size,
hash: asset.hash || '',
dependencies: [],
labels: [],
labels: meta?.labels || [],
tags: new Map(),
lastModified: asset.lastModified,
version: 1
version: 1,
// 包含 importSettings包含 spriteSettings 等)用于渲染系统查询
// Include importSettings (contains spriteSettings etc.) for render systems to query
importSettings: meta?.importSettings as Record<string, unknown> | undefined
});
}
@@ -684,10 +830,13 @@ export class EngineService {
size: asset.size,
hash: asset.hash || '',
dependencies: [],
labels: [],
labels: meta?.labels || [],
tags: new Map(),
lastModified: asset.lastModified,
version: 1
version: 1,
// 包含 importSettings包含 spriteSettings 等)用于渲染系统查询
// Include importSettings (contains spriteSettings etc.) for render systems to query
importSettings: meta?.importSettings as Record<string, unknown> | undefined
});
logger.debug(`Asset synced to runtime: ${asset.path} (${data.guid})`);
@@ -1137,11 +1286,29 @@ export class EngineService {
/**
* Set selected entity IDs for gizmo display.
* 设置选中的实体 ID 用于 Gizmo 显示。
*/
setSelectedEntityIds(ids: number[]): void {
this._selectedEntityIds = [...ids];
this._runtime?.setSelectedEntityIds(ids);
}
/**
* Get currently selected entity IDs.
* 获取当前选中的实体 IDs。
*/
getSelectedEntityIds(): number[] {
return [...this._selectedEntityIds];
}
/**
* Get gizmo interaction service.
* 获取 Gizmo 交互服务。
*/
getGizmoInteractionService(): GizmoInteractionService | null {
return this._gizmoInteractionService;
}
/**
* Set transform tool mode.
*/
@@ -1229,6 +1396,76 @@ export class EngineService {
return this._runtime;
}
/**
* Reinitialize dynamic atlas with current settings.
* 使用当前设置重新初始化动态图集。
*
* Call this when dynamic atlas settings change to apply them.
* 当动态图集设置更改时调用此方法以应用更改。
*/
reinitializeDynamicAtlas(): void {
const bridge = this._runtime?.bridge;
if (!bridge?.createBlankTexture || !bridge?.updateTextureRegion) {
logger.warn('Dynamic atlas requires createBlankTexture and updateTextureRegion');
return;
}
const atlasBridge: IAtlasEngineBridge = {
createBlankTexture: (width: number, height: number) => {
return bridge.createBlankTexture!(width, height);
},
updateTextureRegion: (
id: number,
x: number,
y: number,
width: number,
height: number,
pixels: Uint8Array
) => {
bridge.updateTextureRegion!(id, x, y, width, height, pixels);
}
};
// 从设置中获取动态图集配置
// Get dynamic atlas config from settings
const settingsService = SettingsService.getInstance();
const atlasEnabled = settingsService.get('project.dynamicAtlas.enabled', true);
if (!atlasEnabled) {
logger.info('Dynamic atlas is disabled');
return;
}
const strategyValue = settingsService.get<string>('project.dynamicAtlas.expansionStrategy', 'fixed');
const expansionStrategy = strategyValue === 'dynamic'
? AtlasExpansionStrategy.Dynamic
: AtlasExpansionStrategy.Fixed;
const fixedPageSize = settingsService.get('project.dynamicAtlas.fixedPageSize', 1024);
const maxPages = settingsService.get('project.dynamicAtlas.maxPages', 4);
const maxTextureSize = settingsService.get('project.dynamicAtlas.maxTextureSize', 512);
logger.info('Dynamic atlas settings read from SettingsService:', {
strategyValue,
expansionStrategy: expansionStrategy === AtlasExpansionStrategy.Dynamic ? 'dynamic' : 'fixed',
fixedPageSize,
maxPages,
maxTextureSize
});
const config: DynamicAtlasConfig = {
expansionStrategy,
initialPageSize: 256,
fixedPageSize,
maxPageSize: 2048,
maxPages,
maxTextureSize,
padding: 1
};
reinitializeDynamicAtlasService(atlasBridge, config);
logger.info('Dynamic atlas reinitialized with config:', config);
}
/**
* Dispose engine resources.
*/
@@ -1242,8 +1479,13 @@ export class EngineService {
// 切换项目时清空数据库以释放内存
this._assetManager.getDatabase().clear();
this._assetManager = null;
// 清除全局资产数据库引用 | Clear global asset database reference
setGlobalAssetDatabase(null);
}
// 清除全局引擎桥引用 | Clear global engine bridge reference
setGlobalEngineBridge(null);
this._engineIntegration = null;
if (this._runtime) {

View File

@@ -10,7 +10,7 @@ import { Core, Entity } from '@esengine/ecs-framework';
import { TransformComponent } from '@esengine/engine-core';
import { SpriteComponent } from '@esengine/sprite';
import { ParticleSystemComponent } from '@esengine/particle';
import { UITransformComponent, UIRenderComponent, UITextComponent } from '@esengine/ui';
import { UITransformComponent, UIRenderComponent, UITextComponent, getUIRenderCollector, type BatchDebugInfo, registerTexturePathMapping, getDynamicAtlasService } from '@esengine/ui';
import { AssetRegistryService, ProjectService } from '@esengine/editor-core';
import { invoke } from '@tauri-apps/api/core';
@@ -26,6 +26,15 @@ export interface TextureDebugInfo {
state: 'loading' | 'ready' | 'failed';
}
/**
* Shader uniform 值
* Shader uniform value
*/
export interface UniformDebugValue {
type: 'float' | 'vec2' | 'vec3' | 'vec4' | 'color' | 'int';
value: number | number[];
}
/**
* Sprite 调试信息
* Sprite debug info
@@ -47,6 +56,14 @@ export interface SpriteDebugInfo {
alpha: number;
sortingLayer: string;
orderInLayer: number;
/** 材质/着色器 ID | Material/Shader ID */
materialId: number;
/** 着色器名称 | Shader name */
shaderName: string;
/** Shader uniform 覆盖值 | Shader uniform override values */
uniforms: Record<string, UniformDebugValue>;
/** 顶点属性: 宽高比 (width/height) | Vertex attribute: aspect ratio */
aspectRatio: number;
}
/**
@@ -103,17 +120,86 @@ export interface UIDebugInfo {
alpha: number;
sortingLayer: string;
orderInLayer: number;
/** 层级深度(从根节点计算)| Hierarchy depth (from root) */
depth: number;
/** 世界层内顺序 = depth * 1000 + orderInLayer | World order in layer */
worldOrderInLayer: number;
textureGuid?: string;
textureUrl?: string;
backgroundColor?: string;
text?: string;
fontSize?: number;
/** 材质/着色器 ID | Material/Shader ID */
materialId: number;
/** 着色器名称 | Shader name */
shaderName: string;
/** Shader uniform 覆盖值 | Shader uniform override values */
uniforms: Record<string, UniformDebugValue>;
/** 顶点属性: 宽高比 (width/height) | Vertex attribute: aspect ratio */
aspectRatio: number;
}
/**
* 渲染调试快照
* Render debug snapshot
*/
/**
* 图集条目调试信息
* Atlas entry debug info
*/
export interface AtlasEntryDebugInfo {
/** 纹理 GUID | Texture GUID */
guid: string;
/** 在图集中的位置 | Position in atlas */
x: number;
y: number;
width: number;
height: number;
/** UV 坐标 | UV coordinates */
uv: [number, number, number, number];
/** 纹理图像 data URL用于预览| Texture image data URL (for preview) */
dataUrl?: string;
}
/**
* 图集页面调试信息
* Atlas page debug info
*/
export interface AtlasPageDebugInfo {
/** 页面索引 | Page index */
pageIndex: number;
/** 纹理 ID | Texture ID */
textureId: number;
/** 页面尺寸 | Page size */
width: number;
height: number;
/** 占用率 | Occupancy */
occupancy: number;
/** 此页面中的条目 | Entries in this page */
entries: AtlasEntryDebugInfo[];
}
/**
* 动态图集统计信息
* Dynamic atlas statistics
*/
export interface AtlasStats {
/** 是否启用 | Whether enabled */
enabled: boolean;
/** 图集页数 | Number of atlas pages */
pageCount: number;
/** 已加入图集的纹理数 | Number of textures in atlas */
textureCount: number;
/** 平均占用率 | Average occupancy */
averageOccupancy: number;
/** 正在加载的纹理数 | Number of loading textures */
loadingCount: number;
/** 加载失败的纹理数 | Number of failed textures */
failedCount: number;
/** 每个页面的详细信息 | Detailed info for each page */
pages: AtlasPageDebugInfo[];
}
export interface RenderDebugSnapshot {
timestamp: number;
frameNumber: number;
@@ -121,15 +207,42 @@ export interface RenderDebugSnapshot {
sprites: SpriteDebugInfo[];
particles: ParticleDebugInfo[];
uiElements: UIDebugInfo[];
/** UI 合批调试信息 | UI batch debug info */
uiBatches: BatchDebugInfo[];
/** 动态图集统计 | Dynamic atlas stats */
atlasStats?: AtlasStats;
stats: {
totalSprites: number;
totalParticles: number;
totalUIElements: number;
totalTextures: number;
drawCalls: number;
/** UI 批次数 | UI batch count */
uiBatchCount: number;
};
}
/**
* 内置着色器 ID 到名称的映射
* Built-in shader ID to name mapping
*/
const SHADER_NAMES: Record<number, string> = {
0: 'DefaultSprite',
1: 'Grayscale',
2: 'Tint',
3: 'Flash',
4: 'Outline',
5: 'Shiny'
};
/**
* 根据材质/着色器 ID 获取着色器名称
* Get shader name from material/shader ID
*/
function getShaderName(id: number): string {
return SHADER_NAMES[id] ?? `Custom(${id})`;
}
/**
* 渲染调试服务
* Render Debug Service
@@ -187,18 +300,15 @@ export class RenderDebugService {
// 从缓存获取 | Get from cache
if (this._textureCache.has(textureGuid)) {
console.log('[RenderDebugService] Texture from cache:', textureGuid);
return this._textureCache.get(textureGuid);
}
// 如果正在加载中,返回 undefined | If loading, return undefined
if (this._texturePending.has(textureGuid)) {
console.log('[RenderDebugService] Texture loading:', textureGuid);
return undefined;
}
// 异步加载纹理 | Load texture asynchronously
console.log('[RenderDebugService] Starting texture load:', textureGuid);
this._loadTextureToCache(textureGuid);
return undefined;
}
@@ -260,12 +370,16 @@ export class RenderDebugService {
: resolvedPath;
// 通过 Tauri command 读取文件并转为 base64 | Read file via Tauri command and convert to base64
console.log('[RenderDebugService] Loading texture:', fullPath);
const base64 = await invoke<string>('read_file_as_base64', { filePath: fullPath });
const dataUrl = `data:${mimeType};base64,${base64}`;
console.log('[RenderDebugService] Texture loaded, base64 length:', base64.length);
this._textureCache.set(textureGuid, dataUrl);
// 注册 GUID 到 data URL 映射(用于动态图集)
// Register GUID to data URL mapping (for dynamic atlas)
if (isGuid) {
registerTexturePathMapping(textureGuid, dataUrl);
}
} catch (err) {
console.error('[RenderDebugService] Failed to load texture:', textureGuid, err);
} finally {
@@ -285,6 +399,57 @@ export class RenderDebugService {
this._frameNumber++;
// 收集 UI 合批信息 | Collect UI batch info
const uiCollector = getUIRenderCollector();
const uiBatches = [...uiCollector.getBatchDebugInfo()];
// 收集动态图集统计 | Collect dynamic atlas stats
const atlasService = getDynamicAtlasService();
let atlasStats: AtlasStats | undefined;
if (atlasService) {
const stats = atlasService.getStats();
const pageDetails = atlasService.getPageDetails();
// 转换页面详细信息 | Convert page details
const pages: AtlasPageDebugInfo[] = pageDetails.map(page => ({
pageIndex: page.pageIndex,
textureId: page.textureId,
width: page.width,
height: page.height,
occupancy: page.occupancy,
entries: page.entries.map(e => ({
guid: e.guid,
x: e.entry.region.x,
y: e.entry.region.y,
width: e.entry.region.width,
height: e.entry.region.height,
uv: e.entry.uv,
// 从纹理缓存获取 data URL | Get data URL from texture cache
dataUrl: this._textureCache.get(e.guid)
}))
}));
atlasStats = {
enabled: true,
pageCount: stats.pageCount,
textureCount: stats.textureCount,
averageOccupancy: stats.averageOccupancy,
loadingCount: stats.loadingCount,
failedCount: stats.failedCount,
pages
};
} else {
atlasStats = {
enabled: false,
pageCount: 0,
textureCount: 0,
averageOccupancy: 0,
loadingCount: 0,
failedCount: 0,
pages: []
};
}
const snapshot: RenderDebugSnapshot = {
timestamp: Date.now(),
frameNumber: this._frameNumber,
@@ -292,12 +457,15 @@ export class RenderDebugService {
sprites: this._collectSprites(scene.entities.buffer),
particles: this._collectParticles(scene.entities.buffer),
uiElements: this._collectUI(scene.entities.buffer),
uiBatches,
atlasStats,
stats: {
totalSprites: 0,
totalParticles: 0,
totalUIElements: 0,
totalTextures: 0,
drawCalls: 0,
uiBatchCount: uiBatches.length,
},
};
@@ -306,6 +474,7 @@ export class RenderDebugService {
snapshot.stats.totalParticles = snapshot.particles.reduce((sum, p) => sum + p.activeCount, 0);
snapshot.stats.totalUIElements = snapshot.uiElements.length;
snapshot.stats.totalTextures = snapshot.textures.length;
snapshot.stats.drawCalls = uiBatches.length; // UI batches as draw calls
// 保存快照 | Save snapshot
this._snapshots.push(snapshot);
@@ -378,6 +547,24 @@ export class RenderDebugService {
: transform.rotation.z;
const textureGuid = sprite.textureGuid ?? '';
const materialId = sprite.getMaterialId?.() ?? 0;
// 收集 uniform 覆盖值 | Collect uniform override values
const uniforms: Record<string, UniformDebugValue> = {};
const overrides = sprite.materialOverrides ?? {};
for (const [name, override] of Object.entries(overrides)) {
uniforms[name] = {
type: override.type,
value: override.value
};
}
// 计算 aspectRatio (与 Rust 端一致: width / height)
// Calculate aspectRatio (same as Rust side: width / height)
const width = sprite.width * (transform.scale?.x ?? 1);
const height = sprite.height * (transform.scale?.y ?? 1);
const aspectRatio = Math.abs(height) > 0.001 ? width / height : 1.0;
sprites.push({
entityId: entity.id,
entityName: entity.name,
@@ -394,6 +581,10 @@ export class RenderDebugService {
alpha: sprite.alpha,
sortingLayer: sprite.sortingLayer,
orderInLayer: sprite.orderInLayer,
materialId,
shaderName: getShaderName(materialId),
uniforms,
aspectRatio,
});
}
@@ -519,6 +710,30 @@ export class RenderDebugService {
? `#${uiRender.backgroundColor.toString(16).padStart(6, '0')}`
: undefined;
// 获取材质/着色器 ID | Get material/shader ID
const materialId = uiRender?.getMaterialId?.() ?? 0;
// 收集 uniform 覆盖值 | Collect uniform override values
const uniforms: Record<string, UniformDebugValue> = {};
const overrides = uiRender?.materialOverrides ?? {};
for (const [name, override] of Object.entries(overrides)) {
uniforms[name] = {
type: override.type,
value: override.value
};
}
// 计算 aspectRatio (与 Rust 端一致: width / height)
// Calculate aspectRatio (same as Rust side: width / height)
const uiWidth = uiTransform.width * (uiTransform.scaleX ?? 1);
const uiHeight = uiTransform.height * (uiTransform.scaleY ?? 1);
const aspectRatio = Math.abs(uiHeight) > 0.001 ? uiWidth / uiHeight : 1.0;
// 获取世界层内顺序并计算层级深度 | Get world order in layer and compute depth
// worldOrderInLayer = depth * 1000 + orderInLayer
const worldOrderInLayer = uiTransform.worldOrderInLayer ?? uiTransform.orderInLayer;
const depth = Math.floor(worldOrderInLayer / 1000);
uiElements.push({
entityId: entity.id,
entityName: entity.name,
@@ -534,11 +749,17 @@ export class RenderDebugService {
alpha: uiTransform.worldAlpha,
sortingLayer: uiTransform.sortingLayer,
orderInLayer: uiTransform.orderInLayer,
depth,
worldOrderInLayer,
textureGuid: textureGuid || undefined,
textureUrl: textureGuid ? this._resolveTextureUrl(textureGuid) : undefined,
backgroundColor,
text: uiText?.text,
fontSize: uiText?.fontSize,
materialId,
shaderName: getShaderName(materialId),
uniforms,
aspectRatio,
});
}

View File

@@ -49,6 +49,9 @@ export class TauriAssetReader implements IAssetReader {
return new Promise((resolve, reject) => {
const image = new Image();
// 允许跨域访问,防止 canvas 被污染
// Allow cross-origin access to prevent canvas tainting
image.crossOrigin = 'anonymous';
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(`Failed to load image: ${absolutePath}`));
image.src = assetUrl;

View File

@@ -22,6 +22,9 @@ body {
background-color: var(--color-bg-base);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* 禁用全局文本选择,原生应用风格 | Disable global text selection for native app feel */
user-select: none;
-webkit-user-select: none;
}
button {
@@ -35,6 +38,9 @@ textarea,
select {
font-family: inherit;
font-size: inherit;
/* 输入框允许文本选择 | Allow text selection in inputs */
user-select: text;
-webkit-user-select: text;
}
:focus-visible {
@@ -47,6 +53,18 @@ select {
color: var(--color-text-inverse);
}
/* 允许特定元素文本选择 | Allow text selection for specific elements */
.selectable,
pre,
code,
.code-preview-content,
.file-preview-content,
.output-log-content,
.json-viewer {
user-select: text;
-webkit-user-select: text;
}
/* 全局滚动条样式 */
::-webkit-scrollbar {
width: 8px;

View File

@@ -0,0 +1,262 @@
/**
* Gizmo Hit Tester
* Gizmo 命中测试器
*
* Implements hit testing algorithms for various gizmo types in TypeScript.
* 在 TypeScript 端实现各种 Gizmo 类型的命中测试算法。
*/
import type {
IGizmoRenderData,
IRectGizmoData,
ICircleGizmoData,
ILineGizmoData,
ICapsuleGizmoData
} from './IGizmoProvider';
/**
* Gizmo Hit Tester
* Gizmo 命中测试器
*
* Provides static methods for testing if a point intersects with various gizmo shapes.
* 提供静态方法来测试点是否与各种 gizmo 形状相交。
*/
export class GizmoHitTester {
/** Line hit tolerance in world units (adjusted by zoom) | 线条命中容差(世界单位,根据缩放调整) */
private static readonly BASE_LINE_TOLERANCE = 8;
/**
* Test if point is inside a rect gizmo (considers rotation and origin)
* 测试点是否在矩形 gizmo 内(考虑旋转和原点)
*
* @param worldX World X coordinate | 世界 X 坐标
* @param worldY World Y coordinate | 世界 Y 坐标
* @param rect Rect gizmo data | 矩形 gizmo 数据
* @returns True if point is inside | 如果点在内部返回 true
*/
static hitTestRect(worldX: number, worldY: number, rect: IRectGizmoData): boolean {
const cx = rect.x;
const cy = rect.y;
const halfW = rect.width / 2;
const halfH = rect.height / 2;
const rotation = rect.rotation || 0;
// Transform point to rect's local coordinate system (inverse rotation)
// 将点转换到矩形的本地坐标系(逆旋转)
const cos = Math.cos(-rotation);
const sin = Math.sin(-rotation);
const dx = worldX - cx;
const dy = worldY - cy;
const localX = dx * cos - dy * sin;
const localY = dx * sin + dy * cos;
// Adjust for origin offset
// 根据原点偏移调整
const originOffsetX = (rect.originX - 0.5) * rect.width;
const originOffsetY = (rect.originY - 0.5) * rect.height;
const adjustedX = localX + originOffsetX;
const adjustedY = localY + originOffsetY;
return adjustedX >= -halfW && adjustedX <= halfW &&
adjustedY >= -halfH && adjustedY <= halfH;
}
/**
* Test if point is inside a circle gizmo
* 测试点是否在圆形 gizmo 内
*
* @param worldX World X coordinate | 世界 X 坐标
* @param worldY World Y coordinate | 世界 Y 坐标
* @param circle Circle gizmo data | 圆形 gizmo 数据
* @returns True if point is inside | 如果点在内部返回 true
*/
static hitTestCircle(worldX: number, worldY: number, circle: ICircleGizmoData): boolean {
const dx = worldX - circle.x;
const dy = worldY - circle.y;
const distSq = dx * dx + dy * dy;
return distSq <= circle.radius * circle.radius;
}
/**
* Test if point is near a line gizmo
* 测试点是否在线条 gizmo 附近
*
* @param worldX World X coordinate | 世界 X 坐标
* @param worldY World Y coordinate | 世界 Y 坐标
* @param line Line gizmo data | 线条 gizmo 数据
* @param tolerance Hit tolerance in world units | 命中容差(世界单位)
* @returns True if point is within tolerance of line | 如果点在线条容差范围内返回 true
*/
static hitTestLine(worldX: number, worldY: number, line: ILineGizmoData, tolerance: number): boolean {
const points = line.points;
if (points.length < 2) return false;
const count = line.closed ? points.length : points.length - 1;
for (let i = 0; i < count; i++) {
const p1 = points[i];
const p2 = points[(i + 1) % points.length];
if (this.pointToSegmentDistance(worldX, worldY, p1.x, p1.y, p2.x, p2.y) <= tolerance) {
return true;
}
}
return false;
}
/**
* Test if point is inside a capsule gizmo
* 测试点是否在胶囊 gizmo 内
*
* @param worldX World X coordinate | 世界 X 坐标
* @param worldY World Y coordinate | 世界 Y 坐标
* @param capsule Capsule gizmo data | 胶囊 gizmo 数据
* @returns True if point is inside | 如果点在内部返回 true
*/
static hitTestCapsule(worldX: number, worldY: number, capsule: ICapsuleGizmoData): boolean {
const cx = capsule.x;
const cy = capsule.y;
const rotation = capsule.rotation || 0;
// Transform point to capsule's local coordinate system
// 将点转换到胶囊的本地坐标系
const cos = Math.cos(-rotation);
const sin = Math.sin(-rotation);
const dx = worldX - cx;
const dy = worldY - cy;
const localX = dx * cos - dy * sin;
const localY = dx * sin + dy * cos;
// Capsule = two half-circles + middle rectangle
// 胶囊 = 两个半圆 + 中间矩形
const topCircleY = capsule.halfHeight;
const bottomCircleY = -capsule.halfHeight;
// Check if inside middle rectangle
// 检查是否在中间矩形内
if (Math.abs(localY) <= capsule.halfHeight && Math.abs(localX) <= capsule.radius) {
return true;
}
// Check if inside top half-circle
// 检查是否在上半圆内
const distToTopSq = localX * localX + (localY - topCircleY) * (localY - topCircleY);
if (distToTopSq <= capsule.radius * capsule.radius) {
return true;
}
// Check if inside bottom half-circle
// 检查是否在下半圆内
const distToBottomSq = localX * localX + (localY - bottomCircleY) * (localY - bottomCircleY);
if (distToBottomSq <= capsule.radius * capsule.radius) {
return true;
}
return false;
}
/**
* Generic hit test for any gizmo type
* 通用命中测试,适用于任何 gizmo 类型
*
* @param worldX World X coordinate | 世界 X 坐标
* @param worldY World Y coordinate | 世界 Y 坐标
* @param gizmo Gizmo data | Gizmo 数据
* @param zoom Current viewport zoom level | 当前视口缩放级别
* @returns True if point hits the gizmo | 如果点命中 gizmo 返回 true
*/
static hitTest(worldX: number, worldY: number, gizmo: IGizmoRenderData, zoom: number = 1): boolean {
// Convert screen pixel tolerance to world units
// 将屏幕像素容差转换为世界单位
const lineTolerance = this.BASE_LINE_TOLERANCE / zoom;
switch (gizmo.type) {
case 'rect':
return this.hitTestRect(worldX, worldY, gizmo);
case 'circle':
return this.hitTestCircle(worldX, worldY, gizmo);
case 'line':
return this.hitTestLine(worldX, worldY, gizmo, lineTolerance);
case 'capsule':
return this.hitTestCapsule(worldX, worldY, gizmo);
case 'grid':
// Grid typically doesn't need hit testing
// 网格通常不需要命中测试
return false;
default:
return false;
}
}
/**
* Calculate distance from point to line segment
* 计算点到线段的距离
*
* @param px Point X | 点 X
* @param py Point Y | 点 Y
* @param x1 Segment start X | 线段起点 X
* @param y1 Segment start Y | 线段起点 Y
* @param x2 Segment end X | 线段终点 X
* @param y2 Segment end Y | 线段终点 Y
* @returns Distance from point to segment | 点到线段的距离
*/
private static pointToSegmentDistance(
px: number, py: number,
x1: number, y1: number,
x2: number, y2: number
): number {
const dx = x2 - x1;
const dy = y2 - y1;
const lengthSq = dx * dx + dy * dy;
if (lengthSq === 0) {
// Segment degenerates to a point
// 线段退化为点
return Math.sqrt((px - x1) * (px - x1) + (py - y1) * (py - y1));
}
// Calculate projection parameter t
// 计算投影参数 t
let t = ((px - x1) * dx + (py - y1) * dy) / lengthSq;
t = Math.max(0, Math.min(1, t));
// Nearest point on segment
// 线段上最近的点
const nearestX = x1 + t * dx;
const nearestY = y1 + t * dy;
return Math.sqrt((px - nearestX) * (px - nearestX) + (py - nearestY) * (py - nearestY));
}
/**
* Get the center point of any gizmo
* 获取任意 gizmo 的中心点
*
* @param gizmo Gizmo data | Gizmo 数据
* @returns Center point { x, y } | 中心点 { x, y }
*/
static getGizmoCenter(gizmo: IGizmoRenderData): { x: number; y: number } {
switch (gizmo.type) {
case 'rect':
case 'circle':
case 'capsule':
return { x: gizmo.x, y: gizmo.y };
case 'line':
if (gizmo.points.length === 0) return { x: 0, y: 0 };
const sumX = gizmo.points.reduce((sum, p) => sum + p.x, 0);
const sumY = gizmo.points.reduce((sum, p) => sum + p.y, 0);
return {
x: sumX / gizmo.points.length,
y: sumY / gizmo.points.length
};
case 'grid':
return {
x: gizmo.x + gizmo.width / 2,
y: gizmo.y + gizmo.height / 2
};
default:
return { x: 0, y: 0 };
}
}
}

View File

@@ -10,3 +10,4 @@
export * from './IGizmoProvider';
export * from './GizmoRegistry';
export * from './GizmoHitTester';

View File

@@ -394,12 +394,28 @@ export class AssetRegistryService implements IService {
// 处理文件创建 - 注册新资产并生成 .meta
if (changeType === 'create' || changeType === 'modify') {
for (const absolutePath of paths) {
// Handle .meta file changes - invalidate cache
// 处理 .meta 文件变化 - 使缓存失效
// Handle .meta file changes - invalidate cache and notify listeners
// 处理 .meta 文件变化 - 使缓存失效并通知监听者
if (absolutePath.endsWith('.meta')) {
const assetPath = absolutePath.slice(0, -5); // Remove '.meta' suffix
this._metaManager.invalidateCache(assetPath);
logger.debug(`Meta file changed, invalidated cache for: ${assetPath}`);
// Notify listeners that the asset's metadata has changed
// 通知监听者资产的元数据已变化
const relativePath = this.absoluteToRelative(assetPath);
if (relativePath) {
const metadata = this._database.getMetadataByPath(relativePath);
if (metadata) {
this._messageHub?.publish('assets:changed', {
type: 'modify',
path: assetPath,
relativePath,
guid: metadata.guid
});
logger.debug(`Published assets:changed for meta file: ${relativePath}`);
}
}
continue;
}

View File

@@ -868,6 +868,7 @@ ${userScriptImports}
type: string;
size: number;
hash: string;
importSettings?: Record<string, unknown>;
}>
};
@@ -952,7 +953,10 @@ ${userScriptImports}
path: relativePath,
type: assetType,
size,
hash: hashFileInfo(relativePath, size)
hash: hashFileInfo(relativePath, size),
// Include importSettings for sprite slicing info (nine-patch, etc.)
// 包含 importSettings 以支持精灵切片信息(九宫格等)
importSettings: meta.importSettings
};
addedEntries++;
} catch (error) {

View File

@@ -0,0 +1,302 @@
/**
* Gizmo Interaction Service
* Gizmo 交互服务
*
* Manages gizmo hover detection, highlighting, and click selection.
* 管理 Gizmo 的悬停检测、高亮显示和点击选择。
*/
import { Core } from '@esengine/ecs-framework';
import type { Entity, ComponentType } from '@esengine/ecs-framework';
import { GizmoHitTester } from '../Gizmos/GizmoHitTester';
import { GizmoRegistry } from '../Gizmos/GizmoRegistry';
import type { IGizmoRenderData, GizmoColor } from '../Gizmos/IGizmoProvider';
/**
* Gizmo hit result
* Gizmo 命中结果
*/
export interface GizmoHitResult {
/** Hit gizmo data | 命中的 Gizmo 数据 */
gizmo: IGizmoRenderData;
/** Associated entity ID | 关联的实体 ID */
entityId: number;
/** Distance from hit point to gizmo center | 命中点到 Gizmo 中心的距离 */
distance: number;
}
/**
* Gizmo interaction service interface
* Gizmo 交互服务接口
*/
export interface IGizmoInteractionService {
/**
* Get currently hovered entity ID
* 获取当前悬停的实体 ID
*/
getHoveredEntityId(): number | null;
/**
* Update mouse position and perform hit test
* 更新鼠标位置并执行命中测试
*
* @param worldX World X coordinate | 世界 X 坐标
* @param worldY World Y coordinate | 世界 Y 坐标
* @param zoom Current viewport zoom level | 当前视口缩放级别
*/
updateMousePosition(worldX: number, worldY: number, zoom: number): void;
/**
* Get highlight color for entity (applies hover effect if applicable)
* 获取实体的高亮颜色(如果适用则应用悬停效果)
*
* @param entityId Entity ID | 实体 ID
* @param baseColor Base gizmo color | 基础 Gizmo 颜色
* @param isSelected Whether entity is selected | 实体是否被选中
* @returns Adjusted color | 调整后的颜色
*/
getHighlightColor(entityId: number, baseColor: GizmoColor, isSelected: boolean): GizmoColor;
/**
* Handle click at position, return hit entity ID
* 处理位置点击,返回命中的实体 ID
*
* @param worldX World X coordinate | 世界 X 坐标
* @param worldY World Y coordinate | 世界 Y 坐标
* @param zoom Current viewport zoom level | 当前视口缩放级别
* @returns Hit entity ID or null | 命中的实体 ID 或 null
*/
handleClick(worldX: number, worldY: number, zoom: number): number | null;
/**
* Clear hover state
* 清除悬停状态
*/
clearHover(): void;
}
/**
* Gizmo Interaction Service
* Gizmo 交互服务
*
* Manages gizmo hover detection, highlighting, and click selection.
* 管理 Gizmo 的悬停检测、高亮显示和点击选择。
*/
export class GizmoInteractionService implements IGizmoInteractionService {
private hoveredEntityId: number | null = null;
private hoveredGizmo: IGizmoRenderData | null = null;
/** Hover color multiplier for RGB channels | 悬停时 RGB 通道的颜色倍增 */
private static readonly HOVER_COLOR_MULTIPLIER = 1.3;
/** Hover alpha boost | 悬停时 Alpha 增量 */
private static readonly HOVER_ALPHA_BOOST = 0.3;
// ===== Click cycling state | 点击循环状态 =====
/** Last click position | 上次点击位置 */
private lastClickPos: { x: number; y: number } | null = null;
/** Last click time | 上次点击时间 */
private lastClickTime: number = 0;
/** All hit entities at current click position | 当前点击位置的所有命中实体 */
private hitEntitiesAtClick: number[] = [];
/** Current cycle index | 当前循环索引 */
private cycleIndex: number = 0;
/** Position tolerance for same-position detection | 判断相同位置的容差 */
private static readonly CLICK_POSITION_TOLERANCE = 5;
/** Time tolerance for cycling (ms) | 循环的时间容差(毫秒) */
private static readonly CLICK_TIME_TOLERANCE = 1000;
/**
* Get currently hovered entity ID
* 获取当前悬停的实体 ID
*/
getHoveredEntityId(): number | null {
return this.hoveredEntityId;
}
/**
* Get currently hovered gizmo data
* 获取当前悬停的 Gizmo 数据
*/
getHoveredGizmo(): IGizmoRenderData | null {
return this.hoveredGizmo;
}
/**
* Update mouse position and perform hit test
* 更新鼠标位置并执行命中测试
*/
updateMousePosition(worldX: number, worldY: number, zoom: number): void {
const scene = Core.scene;
if (!scene) {
this.hoveredEntityId = null;
this.hoveredGizmo = null;
return;
}
let closestHit: GizmoHitResult | null = null;
let closestDistance = Infinity;
// Iterate all entities and collect gizmo data for hit testing
// 遍历所有实体,收集 gizmo 数据进行命中测试
for (const entity of scene.entities.buffer) {
// Skip entities without gizmo providers
// 跳过没有 gizmo 提供者的实体
if (!GizmoRegistry.hasAnyGizmoProvider(entity)) {
continue;
}
for (const component of entity.components) {
const componentType = component.constructor as ComponentType;
if (!GizmoRegistry.hasProvider(componentType)) {
continue;
}
const gizmos = GizmoRegistry.getGizmoData(component, entity, false);
for (const gizmo of gizmos) {
if (GizmoHitTester.hitTest(worldX, worldY, gizmo, zoom)) {
// Calculate distance to gizmo center for sorting
// 计算到 gizmo 中心的距离用于排序
const center = GizmoHitTester.getGizmoCenter(gizmo);
const distance = Math.sqrt(
(worldX - center.x) ** 2 + (worldY - center.y) ** 2
);
if (distance < closestDistance) {
closestDistance = distance;
closestHit = {
gizmo,
entityId: entity.id,
distance
};
}
}
}
}
}
this.hoveredEntityId = closestHit?.entityId ?? null;
this.hoveredGizmo = closestHit?.gizmo ?? null;
}
/**
* Get highlight color for entity
* 获取实体的高亮颜色
*/
getHighlightColor(entityId: number, baseColor: GizmoColor, isSelected: boolean): GizmoColor {
const isHovered = entityId === this.hoveredEntityId;
if (!isHovered) {
return baseColor;
}
// Apply hover highlight: brighten color and increase alpha
// 应用悬停高亮:提亮颜色并增加透明度
return {
r: Math.min(1, baseColor.r * GizmoInteractionService.HOVER_COLOR_MULTIPLIER),
g: Math.min(1, baseColor.g * GizmoInteractionService.HOVER_COLOR_MULTIPLIER),
b: Math.min(1, baseColor.b * GizmoInteractionService.HOVER_COLOR_MULTIPLIER),
a: Math.min(1, baseColor.a + GizmoInteractionService.HOVER_ALPHA_BOOST)
};
}
/**
* Handle click at position, return hit entity ID
* Supports cycling through overlapping entities on repeated clicks
* 处理位置点击,返回命中的实体 ID
* 支持重复点击时循环选择重叠的实体
*/
handleClick(worldX: number, worldY: number, zoom: number): number | null {
const now = Date.now();
const isSamePosition = this.lastClickPos !== null &&
Math.abs(worldX - this.lastClickPos.x) < GizmoInteractionService.CLICK_POSITION_TOLERANCE / zoom &&
Math.abs(worldY - this.lastClickPos.y) < GizmoInteractionService.CLICK_POSITION_TOLERANCE / zoom;
const isWithinTimeWindow = (now - this.lastClickTime) < GizmoInteractionService.CLICK_TIME_TOLERANCE;
// If clicking at same position within time window, cycle to next entity
// 如果在时间窗口内点击相同位置,循环到下一个实体
if (isSamePosition && isWithinTimeWindow && this.hitEntitiesAtClick.length > 1) {
this.cycleIndex = (this.cycleIndex + 1) % this.hitEntitiesAtClick.length;
this.lastClickTime = now;
const selectedId = this.hitEntitiesAtClick[this.cycleIndex];
this.hoveredEntityId = selectedId;
return selectedId;
}
// New position or timeout - collect all hit entities
// 新位置或超时 - 收集所有命中的实体
this.hitEntitiesAtClick = this.collectAllHitEntities(worldX, worldY, zoom);
this.cycleIndex = 0;
this.lastClickPos = { x: worldX, y: worldY };
this.lastClickTime = now;
if (this.hitEntitiesAtClick.length > 0) {
const selectedId = this.hitEntitiesAtClick[0];
this.hoveredEntityId = selectedId;
return selectedId;
}
return null;
}
/**
* Collect all entities hit at the given position, sorted by distance
* 收集给定位置命中的所有实体,按距离排序
*/
private collectAllHitEntities(worldX: number, worldY: number, zoom: number): number[] {
const scene = Core.scene;
if (!scene) return [];
const hits: GizmoHitResult[] = [];
for (const entity of scene.entities.buffer) {
if (!GizmoRegistry.hasAnyGizmoProvider(entity)) {
continue;
}
let entityHit = false;
let minDistance = Infinity;
for (const component of entity.components) {
const componentType = component.constructor as ComponentType;
if (!GizmoRegistry.hasProvider(componentType)) {
continue;
}
const gizmos = GizmoRegistry.getGizmoData(component, entity, false);
for (const gizmo of gizmos) {
if (GizmoHitTester.hitTest(worldX, worldY, gizmo, zoom)) {
entityHit = true;
const center = GizmoHitTester.getGizmoCenter(gizmo);
const distance = Math.sqrt(
(worldX - center.x) ** 2 + (worldY - center.y) ** 2
);
minDistance = Math.min(minDistance, distance);
}
}
}
if (entityHit) {
hits.push({
gizmo: {} as IGizmoRenderData, // Not needed for sorting
entityId: entity.id,
distance: minDistance
});
}
}
// Sort by distance (closest first)
// 按距离排序(最近的在前)
hits.sort((a, b) => a.distance - b.distance);
return hits.map(hit => hit.entityId);
}
/**
* Clear hover state
* 清除悬停状态
*/
clearHover(): void {
this.hoveredEntityId = null;
this.hoveredGizmo = null;
}
}

View File

@@ -24,6 +24,7 @@ import type { LocaleService, Locale, TranslationParams, PluginTranslations } fro
import type { MessageHub, MessageHandler, RequestHandler } from './Services/MessageHub';
import type { EntityStoreService, EntityTreeNode } from './Services/EntityStoreService';
import type { PrefabService, PrefabPropertyOverride, IPrefabFileAPI } from './Services/PrefabService';
import type { IGizmoInteractionService } from './Services/GizmoInteractionService';
// ============================================================================
// LocaleService Token
@@ -203,9 +204,28 @@ export const PrefabServiceToken = createServiceToken<IPrefabService>('prefabServ
// 重新导出类型方便使用
export type { PrefabPropertyOverride, IPrefabFileAPI } from './Services/PrefabService';
// ============================================================================
// GizmoInteractionService Token
// Gizmo 交互服务令牌
// ============================================================================
/**
* Gizmo 交互服务令牌
* Gizmo interaction service token
*
* 用于注册和获取 Gizmo 交互服务。
* For registering and getting gizmo interaction service.
*/
export const GizmoInteractionServiceToken = createServiceToken<IGizmoInteractionService>('gizmoInteractionService');
// Re-export interface for convenience
// 重新导出接口方便使用
export type { IGizmoInteractionService } from './Services/GizmoInteractionService';
// Re-export classes for direct use (backwards compatibility)
// 重新导出类以供直接使用(向后兼容)
export { LocaleService } from './Services/LocaleService';
export { MessageHub } from './Services/MessageHub';
export { EntityStoreService } from './Services/EntityStoreService';
export { PrefabService } from './Services/PrefabService';
export { GizmoInteractionService } from './Services/GizmoInteractionService';

View File

@@ -117,6 +117,39 @@ export interface IEngineBridge {
* @returns 所有纹理加载完成时解析 | Resolves when all textures are loaded
*/
waitForAllTextures?(timeout?: number): Promise<void>;
// ===== Dynamic Atlas API (Optional) =====
// ===== 动态图集 API可选=====
/**
* 创建空白纹理(用于动态图集)
* Create blank texture (for dynamic atlas)
*
* @param width 宽度 | Width
* @param height 高度 | Height
* @returns 纹理 ID | Texture ID
*/
createBlankTexture?(width: number, height: number): number;
/**
* 更新纹理区域
* Update texture region
*
* @param id 纹理 ID | Texture ID
* @param x X 坐标 | X coordinate
* @param y Y 坐标 | Y coordinate
* @param width 宽度 | Width
* @param height 高度 | Height
* @param pixels RGBA 像素数据 | RGBA pixel data
*/
updateTextureRegion?(
id: number,
x: number,
y: number,
width: number,
height: number,
pixels: Uint8Array
): void;
}
/**

View File

@@ -81,14 +81,15 @@ export class TransformSystem extends EntitySystem {
const sin = Math.sin(rad);
// 构建仿射变换矩阵: Scale -> Rotate -> Translate
// [a c tx] [sx 0 0] [cos -sin 0] [1 0 tx]
// [b d ty] = [0 sy 0] * [sin cos 0] * [0 1 ty]
// 顺时针旋转 | Clockwise rotation
// [a c tx] [sx 0 0] [cos sin 0] [1 0 tx]
// [b d ty] = [0 sy 0] * [-sin cos 0] * [0 1 ty]
// [0 0 1] [0 0 1] [0 0 1] [0 0 1]
return {
a: scale.x * cos,
b: scale.x * sin,
c: scale.y * -sin,
b: -scale.x * sin,
c: scale.y * sin,
d: scale.y * cos,
tx: position.x,
ty: position.y

View File

@@ -349,6 +349,16 @@ impl Engine {
self.texture_manager.get_texture_id_by_path(path)
}
/// Get texture size by path.
/// 按路径获取纹理尺寸。
///
/// Returns None if texture is not loaded or path not found.
/// 如果纹理未加载或路径未找到,返回 None。
pub fn get_texture_size_by_path(&self, path: &str) -> Option<(f32, f32)> {
let id = self.texture_manager.get_texture_id_by_path(path)?;
self.texture_manager.get_texture_size(id)
}
/// Get or load texture by path.
/// 按路径获取或加载纹理。
pub fn get_or_load_by_path(&mut self, path: &str) -> Result<u32> {
@@ -374,6 +384,32 @@ impl Engine {
self.texture_manager.clear_all();
}
/// Create a blank texture for dynamic atlas.
/// 为动态图集创建空白纹理。
///
/// This creates a texture that can be filled later using `update_texture_region`.
/// 创建一个可以稍后使用 `update_texture_region` 填充的纹理。
pub fn create_blank_texture(&mut self, width: u32, height: u32) -> Result<u32> {
self.texture_manager.create_blank_texture(width, height)
}
/// Update a region of an existing texture.
/// 更新现有纹理的区域。
///
/// Used for dynamic atlas to copy textures into the atlas.
/// 用于动态图集将纹理复制到图集中。
pub fn update_texture_region(
&self,
id: u32,
x: u32,
y: u32,
width: u32,
height: u32,
pixels: &[u8],
) -> Result<()> {
self.texture_manager.update_texture_region(id, x, y, width, height, pixels)
}
/// 获取纹理加载状态
/// Get texture loading state
pub fn get_texture_state(&self, id: u32) -> crate::renderer::texture::TextureState {

View File

@@ -212,6 +212,24 @@ impl GameEngine {
self.engine.get_texture_id_by_path(path)
}
/// Get texture size by path.
/// 按路径获取纹理尺寸。
///
/// Returns an array [width, height] or null if not found.
/// 返回数组 [width, height],如果未找到则返回 null。
///
/// # Arguments | 参数
/// * `path` - Image path to lookup | 要查找的图片路径
#[wasm_bindgen(js_name = getTextureSizeByPath)]
pub fn get_texture_size_by_path(&self, path: &str) -> Option<js_sys::Float32Array> {
self.engine.get_texture_size_by_path(path).map(|(w, h)| {
let arr = js_sys::Float32Array::new_with_length(2);
arr.set_index(0, w);
arr.set_index(1, h);
arr
})
}
/// Get or load texture by path.
/// 按路径获取或加载纹理。
///
@@ -722,4 +740,60 @@ impl GameEngine {
pub fn clear_all_textures(&mut self) {
self.engine.clear_all_textures();
}
// ===== 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 元素。
///
/// # 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
#[wasm_bindgen(js_name = createBlankTexture)]
pub fn create_blank_texture(
&mut self,
width: u32,
height: u32,
) -> std::result::Result<u32, JsValue> {
self.engine
.create_blank_texture(width, height)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// 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字节
#[wasm_bindgen(js_name = updateTextureRegion)]
pub fn update_texture_region(
&self,
id: u32,
x: u32,
y: u32,
width: u32,
height: u32,
pixels: &[u8],
) -> std::result::Result<(), JsValue> {
self.engine
.update_texture_region(id, x, y, width, height, pixels)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
}

View File

@@ -82,15 +82,22 @@ impl Transform2D {
///
/// The matrix is constructed as: T * R * S (translate, rotate, scale).
/// 矩阵构造顺序为T * R * S平移、旋转、缩放
///
/// Uses left-hand coordinate system convention:
/// 使用左手坐标系约定:
/// - Positive rotation = clockwise (when viewed from +Z)
/// - 正旋转 = 顺时针(从 +Z 方向观察时)
pub fn to_matrix(&self) -> Mat3 {
let cos = self.rotation.cos();
let sin = self.rotation.sin();
// Construct TRS matrix directly for performance
// 直接构造TRS矩阵以提高性能
// Clockwise rotation: [cos, -sin; sin, cos] (column-major)
// 顺时针旋转矩阵
Mat3::from_cols(
glam::Vec3::new(cos * self.scale.x, sin * self.scale.x, 0.0),
glam::Vec3::new(-sin * self.scale.y, cos * self.scale.y, 0.0),
glam::Vec3::new(cos * self.scale.x, -sin * self.scale.x, 0.0),
glam::Vec3::new(sin * self.scale.y, cos * self.scale.y, 0.0),
glam::Vec3::new(self.position.x, self.position.y, 1.0),
)
}
@@ -101,6 +108,9 @@ impl Transform2D {
/// # Arguments | 参数
/// * `width` - Sprite width | 精灵宽度
/// * `height` - Sprite height | 精灵高度
///
/// Uses left-hand coordinate system (clockwise positive rotation).
/// 使用左手坐标系(顺时针正旋转)。
pub fn to_matrix_with_origin(&self, width: f32, height: f32) -> Mat3 {
let ox = -self.origin.x * width * self.scale.x;
let oy = -self.origin.y * height * self.scale.y;
@@ -108,14 +118,16 @@ impl Transform2D {
let cos = self.rotation.cos();
let sin = self.rotation.sin();
// Apply origin offset after rotation
// 在旋转后应用原点偏移
let tx = self.position.x + ox * cos - oy * sin;
let ty = self.position.y + ox * sin + oy * cos;
// Apply origin offset after rotation (clockwise rotation)
// 在旋转后应用原点偏移(顺时针旋转)
let tx = self.position.x + ox * cos + oy * sin;
let ty = self.position.y - ox * sin + oy * cos;
// Clockwise rotation matrix
// 顺时针旋转矩阵
Mat3::from_cols(
glam::Vec3::new(cos * self.scale.x, sin * self.scale.x, 0.0),
glam::Vec3::new(-sin * self.scale.y, cos * self.scale.y, 0.0),
glam::Vec3::new(cos * self.scale.x, -sin * self.scale.x, 0.0),
glam::Vec3::new(sin * self.scale.y, cos * self.scale.y, 0.0),
glam::Vec3::new(tx, ty, 1.0),
)
}

View File

@@ -113,13 +113,20 @@ impl Vec2 {
/// Rotate the vector by an angle (in radians).
/// 按角度旋转向量(弧度)。
///
/// Uses left-hand coordinate system convention:
/// 使用左手坐标系约定:
/// - Positive angle = clockwise rotation (when viewed from +Z)
/// - 正角度 = 顺时针旋转(从 +Z 方向观察时)
#[inline]
pub fn rotate(&self, angle: f32) -> Self {
let cos = angle.cos();
let sin = angle.sin();
// Clockwise rotation matrix: [cos, sin; -sin, cos]
// 顺时针旋转矩阵
Self {
x: self.x * cos - self.y * sin,
y: self.x * sin + self.y * cos,
x: self.x * cos + self.y * sin,
y: -self.x * sin + self.y * cos,
}
}

View File

@@ -1,7 +1,6 @@
//! Sprite batch renderer for efficient 2D rendering.
//! 用于高效2D渲染的精灵批处理渲染器。
use indexmap::IndexMap;
use web_sys::{
WebGl2RenderingContext, WebGlBuffer, WebGlVertexArrayObject,
};
@@ -66,17 +65,23 @@ pub struct SpriteBatch {
/// 最大精灵数。
max_sprites: usize,
/// Per-material-texture vertex data buffers (insertion-ordered).
/// 按材质和纹理分组的顶点数据缓冲区(保持插入顺序)
/// Batches stored as (key, vertices) pairs in submission order.
/// 按提交顺序存储的批次(键,顶点)对
///
/// Uses IndexMap to preserve render order - sprites submitted first
/// are rendered first (appear behind later sprites).
/// 使用 IndexMap 保持渲染顺序 - 先提交的精灵先渲染(显示在后面)。
batches: IndexMap<BatchKey, Vec<f32>>,
/// Only consecutive sprites with the same BatchKey are batched together.
/// Sprites with the same key but separated by different keys are kept in separate batches
/// to preserve correct render order.
/// 只有连续的相同 BatchKey 的 sprites 才会合批。
/// 相同 key 但被其他 key 分隔的 sprites 保持在独立批次中以保证正确的渲染顺序。
batches: Vec<(BatchKey, Vec<f32>)>,
/// Total sprite count across all batches.
/// 所有批次的总精灵数。
sprite_count: usize,
/// Last batch key used, for determining if we can merge into the last batch.
/// 上一个使用的批次键,用于判断是否可以合并到最后一个批次。
last_batch_key: Option<BatchKey>,
}
impl SpriteBatch {
@@ -140,8 +145,9 @@ impl SpriteBatch {
vbo,
ibo,
max_sprites,
batches: IndexMap::new(),
batches: Vec::new(),
sprite_count: 0,
last_batch_key: None,
})
}
@@ -168,8 +174,15 @@ impl SpriteBatch {
/// Set up vertex attribute pointers.
/// 设置顶点属性指针。
///
/// Vertex layout (9 floats per vertex):
/// 顶点布局(每顶点 9 个浮点数):
/// - location 0: position (2 floats) - offset 0
/// - location 1: tex_coord (2 floats) - offset 8
/// - location 2: color (4 floats) - offset 16
/// - location 3: aspect_ratio (1 float) - offset 32
fn setup_vertex_attributes(gl: &WebGl2RenderingContext) {
let stride = (FLOATS_PER_VERTEX * 4) as i32;
let stride = (FLOATS_PER_VERTEX * 4) as i32; // 9 * 4 = 36 bytes
// Position attribute (location = 0) | 位置属性
gl.enable_vertex_attrib_array(0);
@@ -203,15 +216,27 @@ impl SpriteBatch {
stride,
16, // 4 floats * 4 bytes
);
// Aspect ratio attribute (location = 3) | 宽高比属性
// Used by shaders for aspect-ratio-aware transformations
// 用于着色器中的宽高比感知变换
gl.enable_vertex_attrib_array(3);
gl.vertex_attrib_pointer_with_i32(
3,
1,
WebGl2RenderingContext::FLOAT,
false,
stride,
32, // (2 + 2 + 4) floats * 4 bytes
);
}
/// Clear the batch for a new frame.
/// 为新帧清空批处理。
pub fn clear(&mut self) {
for batch in self.batches.values_mut() {
batch.clear();
}
self.batches.clear();
self.sprite_count = 0;
self.last_batch_key = None;
}
/// Add sprites from batch data.
@@ -302,21 +327,40 @@ impl SpriteBatch {
let width = scale_x;
let height = scale_y;
// Calculate aspect ratio (width / height), default 1.0 for degenerate cases
// 计算宽高比(宽度/高度),退化情况下默认为 1.0
let aspect_ratio = if height.abs() > 0.001 {
width / height
} else {
1.0
};
let batch_key = BatchKey {
material_id: material_ids[i],
texture_id: texture_ids[i],
};
// Get or create batch for this material+texture combination | 获取或创建此材质+纹理组合的批次
let batch = self.batches
.entry(batch_key)
.or_insert_with(Vec::new);
// Only batch consecutive sprites with the same key to preserve render order
// 只对连续相同 key 的 sprites 合批以保持渲染顺序
let should_create_new_batch = match self.last_batch_key {
Some(last_key) => batch_key != last_key,
None => true,
};
if should_create_new_batch {
// Create a new batch | 创建新批次
self.batches.push((batch_key, Vec::new()));
self.last_batch_key = Some(batch_key);
}
// Add to the last batch | 添加到最后一个批次
let batch = &mut self.batches.last_mut().unwrap().1;
// Calculate transformed vertices and add to batch | 计算变换后的顶点并添加到批次
Self::add_sprite_vertices_to_batch(
batch,
x, y, width, height, rotation, origin_x, origin_y,
u0, v0, u1, v1, color_arr,
u0, v0, u1, v1, color_arr, aspect_ratio,
);
}
@@ -326,6 +370,9 @@ impl SpriteBatch {
/// Add vertices for a single sprite to a batch.
/// 为单个精灵添加顶点到批次。
///
/// Each vertex contains: position(2) + tex_coord(2) + color(4) + aspect_ratio(1) = 9 floats
/// 每个顶点包含: 位置(2) + 纹理坐标(2) + 颜色(4) + 宽高比(1) = 9 个浮点数
#[inline]
fn add_sprite_vertices_to_batch(
batch: &mut Vec<f32>,
@@ -341,6 +388,7 @@ impl SpriteBatch {
u1: f32,
v1: f32,
color: [f32; 4],
aspect_ratio: f32,
) {
let cos = rotation.cos();
let sin = rotation.sin();
@@ -393,6 +441,10 @@ impl SpriteBatch {
// Color | 颜色
batch.extend_from_slice(&color);
// Aspect ratio (same for all 4 vertices of a quad)
// 宽高比(四边形的 4 个顶点相同)
batch.push(aspect_ratio);
}
}
@@ -432,16 +484,16 @@ impl SpriteBatch {
gl.bind_vertex_array(None);
}
/// Get all batches for rendering (in insertion order).
/// 获取所有批次用于渲染(按插入顺序)。
pub fn batches(&self) -> &IndexMap<BatchKey, Vec<f32>> {
/// Get all batches for rendering (in submission order).
/// 获取所有批次用于渲染(按提交顺序)。
pub fn batches(&self) -> &[(BatchKey, Vec<f32>)] {
&self.batches
}
/// Flush a specific batch by key.
/// 按刷新特定批次。
pub fn flush_for_batch(&self, gl: &WebGl2RenderingContext, key: &BatchKey) {
if let Some(vertices) = self.batches.get(key) {
/// Flush a specific batch by index.
/// 按索引刷新特定批次。
pub fn flush_batch_at(&self, gl: &WebGl2RenderingContext, index: usize) {
if let Some((_, vertices)) = self.batches.get(index) {
self.flush_batch(gl, vertices);
}
}

View File

@@ -9,13 +9,16 @@ pub const VERTEX_SIZE: usize = std::mem::size_of::<SpriteVertex>();
/// Number of floats per vertex.
/// 每个顶点的浮点数数量。
pub const FLOATS_PER_VERTEX: usize = 8;
///
/// Layout: position(2) + tex_coord(2) + color(4) + aspect_ratio(1) = 9
/// 布局: 位置(2) + 纹理坐标(2) + 颜色(4) + 宽高比(1) = 9
pub const FLOATS_PER_VERTEX: usize = 9;
/// Sprite vertex data.
/// 精灵顶点数据。
///
/// Each sprite requires 4 vertices (quad), each with position, UV, and color.
/// 每个精灵需要4个顶点四边形每个顶点包含位置、UV颜色。
/// Each sprite requires 4 vertices (quad), each with position, UV, color, and aspect ratio.
/// 每个精灵需要4个顶点四边形每个顶点包含位置、UV颜色和宽高比
#[derive(Debug, Clone, Copy, Pod, Zeroable)]
#[repr(C)]
pub struct SpriteVertex {
@@ -30,6 +33,15 @@ pub struct SpriteVertex {
/// Color (r, g, b, a).
/// 颜色。
pub color: [f32; 4],
/// Aspect ratio (width / height) for shader effects.
/// 宽高比(宽度/高度),用于着色器效果。
///
/// This allows shaders to apply aspect-ratio-aware transformations
/// (e.g., rotation in shiny effects) without per-instance uniforms.
/// 这允许着色器应用宽高比感知的变换(如闪光效果中的旋转),
/// 无需每实例 uniform。
pub aspect_ratio: f32,
}
impl SpriteVertex {
@@ -40,11 +52,13 @@ impl SpriteVertex {
position: [f32; 2],
tex_coord: [f32; 2],
color: [f32; 4],
aspect_ratio: f32,
) -> Self {
Self {
position,
tex_coord,
color,
aspect_ratio,
}
}
}
@@ -55,6 +69,7 @@ impl Default for SpriteVertex {
position: [0.0, 0.0],
tex_coord: [0.0, 0.0],
color: [1.0, 1.0, 1.0, 1.0],
aspect_ratio: 1.0,
}
}
}

View File

@@ -1,5 +1,12 @@
//! 2D camera implementation.
//! 2D相机实现。
//!
//! Uses left-hand coordinate system convention:
//! 使用左手坐标系约定:
//! - X axis: positive to the right / X 轴:正方向向右
//! - Y axis: positive upward (in world space) / Y 轴:正方向向上(世界空间)
//! - Z axis: positive into the screen / Z 轴:正方向指向屏幕内
//! - Positive rotation: clockwise (when viewed from +Z) / 正旋转:顺时针(从 +Z 观察)
use crate::math::Vec2;
use glam::Mat3;
@@ -67,6 +74,7 @@ impl Camera2D {
/// - World: Y-up, origin at camera position | 世界坐标Y向上原点在相机位置
/// - Screen: Y-down, origin at top-left | 屏幕坐标Y向下原点在左上角
/// - NDC: Y-up, origin at center [-1, 1] | NDCY向上原点在中心
/// - Rotation: positive = clockwise | 旋转:正 = 顺时针
///
/// When zoom=1, 1 world unit = 1 screen pixel.
/// 当zoom=1时1个世界单位 = 1个屏幕像素。
@@ -81,8 +89,8 @@ impl Camera2D {
let sx = 2.0 / self.width * self.zoom;
let sy = 2.0 / self.height * self.zoom;
// Handle rotation
// 处理旋转
// Handle rotation (clockwise positive)
// 处理旋转(顺时针为正)
let cos = self.rotation.cos();
let sin = self.rotation.sin();
@@ -97,15 +105,17 @@ impl Camera2D {
// 组合缩放、旋转和平移
// Matrix = Scale * Rotation * Translation (applied right to left)
// 矩阵 = 缩放 * 旋转 * 平移(从右到左应用)
// Clockwise rotation: [cos, -sin; sin, cos]
// 顺时针旋转矩阵
if self.rotation != 0.0 {
// With rotation: need to rotate the translation as well
// 有旋转时:平移也需要旋转
let rtx = tx * cos - ty * sin;
let rty = tx * sin + ty * cos;
// With rotation: need to rotate the translation as well (clockwise)
// 有旋转时:平移也需要旋转(顺时针)
let rtx = tx * cos + ty * sin;
let rty = -tx * sin + ty * cos;
Mat3::from_cols(
glam::Vec3::new(sx * cos, sx * sin, 0.0),
glam::Vec3::new(-sy * sin, sy * cos, 0.0),
glam::Vec3::new(sx * cos, -sx * sin, 0.0),
glam::Vec3::new(sy * sin, sy * cos, 0.0),
glam::Vec3::new(rtx, rty, 1.0),
)
} else {
@@ -124,6 +134,7 @@ impl Camera2D {
///
/// Screen: (0,0) at top-left, Y-down | 屏幕:(0,0)在左上角Y向下
/// World: Y-up, camera at center | 世界Y向上相机在中心
/// Rotation: positive = clockwise | 旋转:正 = 顺时针
pub fn screen_to_world(&self, screen: Vec2) -> Vec2 {
// Convert screen to NDC-like coordinates (centered, Y-up)
// 将屏幕坐标转换为类NDC坐标居中Y向上
@@ -138,11 +149,15 @@ impl Camera2D {
if self.rotation != 0.0 {
// Apply inverse rotation around camera position
// 围绕相机位置应用反向旋转
// Inverse of clockwise θ is clockwise -θ
// 顺时针 θ 的逆变换是顺时针 -θ
let dx = world_x - self.position.x;
let dy = world_y - self.position.y;
let cos = (-self.rotation).cos();
let sin = (-self.rotation).sin();
let cos = self.rotation.cos(); // cos(-θ) = cos(θ)
let sin = self.rotation.sin(); // for clockwise -θ: use -sin(θ)
// Clockwise rotation with -θ: x' = x*cos + y*(-sin), y' = -x*(-sin) + y*cos
// 用 -θ 做顺时针旋转
Vec2::new(
dx * cos - dy * sin + self.position.x,
dx * sin + dy * cos + self.position.y,
@@ -157,14 +172,19 @@ impl Camera2D {
///
/// World: Y-up | 世界Y向上
/// Screen: (0,0) at top-left, Y-down | 屏幕:(0,0)在左上角Y向下
/// Rotation: positive = clockwise | 旋转:正 = 顺时针
pub fn world_to_screen(&self, world: Vec2) -> Vec2 {
let dx = world.x - self.position.x;
let dy = world.y - self.position.y;
// Apply clockwise rotation
// 应用顺时针旋转
let (rx, ry) = if self.rotation != 0.0 {
let cos = self.rotation.cos();
let sin = self.rotation.sin();
(dx * cos - dy * sin, dx * sin + dy * cos)
// Clockwise: x' = x*cos + y*sin, y' = -x*sin + y*cos
// 顺时针旋转公式
(dx * cos + dy * sin, -dx * sin + dy * cos)
} else {
(dx, dy)
};

View File

@@ -116,19 +116,10 @@ impl Renderer2D {
/// Render the current frame.
/// 渲染当前帧。
pub fn render(&mut self, gl: &WebGl2RenderingContext, texture_manager: &TextureManager) -> Result<()> {
use super::batch::BatchKey;
if self.sprite_batch.sprite_count() == 0 {
return Ok(());
}
// Collect non-empty batch keys | 收集非空批次键
let batch_keys: Vec<BatchKey> = self.sprite_batch.batches()
.iter()
.filter(|(_, vertices)| !vertices.is_empty())
.map(|(key, _)| *key)
.collect();
// Track current state to minimize state changes | 跟踪当前状态以最小化状态切换
let mut current_material_id: u32 = u32::MAX;
let mut current_texture_id: u32 = u32::MAX;
@@ -136,7 +127,16 @@ impl Renderer2D {
// Get projection matrix once | 一次性获取投影矩阵
let projection = self.camera.projection_matrix();
for batch_key in batch_keys {
// Iterate through batches in submission order (preserves render order)
// 按提交顺序遍历批次(保持渲染顺序)
for batch_idx in 0..self.sprite_batch.batches().len() {
let (batch_key, vertices) = &self.sprite_batch.batches()[batch_idx];
// Skip empty batches | 跳过空批次
if vertices.is_empty() {
continue;
}
// Switch material if needed | 如需切换材质
if batch_key.material_id != current_material_id {
current_material_id = batch_key.material_id;
@@ -169,8 +169,8 @@ impl Renderer2D {
texture_manager.bind_texture(batch_key.texture_id, 0);
}
// Flush this batch | 刷新此批次
self.sprite_batch.flush_for_batch(gl, &batch_key);
// Flush this batch by index | 按索引刷新此批次
self.sprite_batch.flush_batch_at(gl, batch_idx);
}
// Clear batch for next frame | 清空批处理以供下一帧使用

View File

@@ -52,6 +52,11 @@ pub struct TextureManager {
/// 纹理加载状态(使用 Rc<RefCell<>> 以便闭包可以修改)
/// Texture loading states (using Rc<RefCell<>> so closures can modify)
texture_states: Rc<RefCell<HashMap<u32, TextureState>>>,
/// 纹理尺寸缓存(使用 Rc<RefCell<>> 以便闭包可以修改)
/// Texture dimensions cache (using Rc<RefCell<>> so closures can modify)
/// Key: texture ID, Value: (width, height)
texture_dimensions: Rc<RefCell<HashMap<u32, (u32, u32)>>>,
}
impl TextureManager {
@@ -65,6 +70,7 @@ impl TextureManager {
next_id: 1, // Start from 1, 0 is reserved for default
default_texture: None,
texture_states: Rc::new(RefCell::new(HashMap::new())),
texture_dimensions: Rc::new(RefCell::new(HashMap::new())),
};
// Create default white texture | 创建默认白色纹理
@@ -150,6 +156,9 @@ impl TextureManager {
let states_for_onload = Rc::clone(&self.texture_states);
let states_for_onerror = Rc::clone(&self.texture_states);
// Clone dimensions map for closure | 克隆尺寸映射用于闭包
let dimensions_for_onload = Rc::clone(&self.texture_dimensions);
// Load actual image asynchronously | 异步加载实际图片
let gl = self.gl.clone();
@@ -205,6 +214,12 @@ impl TextureManager {
WebGl2RenderingContext::LINEAR as i32,
);
// 存储纹理尺寸(从加载的图片获取)
// Store texture dimensions (from loaded image)
let width = image_clone.width();
let height = image_clone.height();
dimensions_for_onload.borrow_mut().insert(texture_id, (width, height));
// 标记为就绪 | Mark as ready
states_for_onload.borrow_mut().insert(texture_id, TextureState::Ready);
@@ -236,8 +251,21 @@ impl TextureManager {
/// Get texture size by ID.
/// 按ID获取纹理尺寸。
///
/// First checks the dimensions cache (updated when texture loads),
/// then falls back to the Texture struct.
/// 首先检查尺寸缓存(在纹理加载时更新),
/// 然后回退到 Texture 结构体。
#[inline]
pub fn get_texture_size(&self, id: u32) -> Option<(f32, f32)> {
// Check dimensions cache first (has actual loaded dimensions)
// 首先检查尺寸缓存(有实际加载的尺寸)
if let Some(&(w, h)) = self.texture_dimensions.borrow().get(&id) {
return Some((w as f32, h as f32));
}
// Fall back to texture struct (may have placeholder dimensions)
// 回退到纹理结构体(可能是占位符尺寸)
self.textures
.get(&id)
.map(|t| (t.width as f32, t.height as f32))
@@ -329,6 +357,8 @@ impl TextureManager {
self.path_to_id.retain(|_, &mut v| v != id);
// Remove state | 移除状态
self.texture_states.borrow_mut().remove(&id);
// Remove dimensions | 移除尺寸
self.texture_dimensions.borrow_mut().remove(&id);
}
/// Load texture by path, returning texture ID.
@@ -409,8 +439,144 @@ impl TextureManager {
// Clear texture states | 清除纹理状态
self.texture_states.borrow_mut().clear();
// Clear texture dimensions | 清除纹理尺寸
self.texture_dimensions.borrow_mut().clear();
// Reset ID counter (1 is reserved for first texture, 0 for default)
// 重置ID计数器1保留给第一个纹理0给默认纹理
self.next_id = 1;
}
/// Create a blank texture with specified dimensions.
/// 创建具有指定尺寸的空白纹理。
///
/// This is used for dynamic atlas creation where textures
/// are later filled with content using `update_texture_region`.
/// 用于动态图集创建,之后使用 `update_texture_region` 填充内容。
///
/// # Arguments | 参数
/// * `width` - Texture width in pixels | 纹理宽度(像素)
/// * `height` - Texture height in pixels | 纹理高度(像素)
///
/// # Returns | 返回
/// The texture ID for the created texture | 创建的纹理ID
pub fn create_blank_texture(&mut self, width: u32, height: u32) -> Result<u32> {
let texture = self.gl
.create_texture()
.ok_or_else(|| EngineError::TextureLoadFailed("Failed to create blank texture".into()))?;
self.gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(&texture));
// Initialize with transparent pixels
// 使用透明像素初始化
let _ = self.gl.tex_image_2d_with_i32_and_i32_and_i32_and_format_and_type_and_opt_u8_array(
WebGl2RenderingContext::TEXTURE_2D,
0,
WebGl2RenderingContext::RGBA as i32,
width as i32,
height as i32,
0,
WebGl2RenderingContext::RGBA,
WebGl2RenderingContext::UNSIGNED_BYTE,
None, // NULL data - allocate but don't fill
);
// Set texture parameters for atlas use
// 设置图集使用的纹理参数
self.gl.tex_parameteri(
WebGl2RenderingContext::TEXTURE_2D,
WebGl2RenderingContext::TEXTURE_WRAP_S,
WebGl2RenderingContext::CLAMP_TO_EDGE as i32,
);
self.gl.tex_parameteri(
WebGl2RenderingContext::TEXTURE_2D,
WebGl2RenderingContext::TEXTURE_WRAP_T,
WebGl2RenderingContext::CLAMP_TO_EDGE as i32,
);
self.gl.tex_parameteri(
WebGl2RenderingContext::TEXTURE_2D,
WebGl2RenderingContext::TEXTURE_MIN_FILTER,
WebGl2RenderingContext::LINEAR as i32,
);
self.gl.tex_parameteri(
WebGl2RenderingContext::TEXTURE_2D,
WebGl2RenderingContext::TEXTURE_MAG_FILTER,
WebGl2RenderingContext::LINEAR as i32,
);
// Assign ID and store
// 分配ID并存储
let id = self.next_id;
self.next_id += 1;
self.textures.insert(id, Texture::new(texture, width, height));
self.texture_states.borrow_mut().insert(id, TextureState::Ready);
self.texture_dimensions.borrow_mut().insert(id, (width, height));
log::debug!("Created blank texture {} ({}x{}) | 创建空白纹理 {} ({}x{})", id, width, height, id, width, height);
Ok(id)
}
/// Update a region of an existing texture with pixel data.
/// 使用像素数据更新现有纹理的区域。
///
/// This is used for dynamic atlas to copy individual textures
/// into the atlas texture.
/// 用于动态图集将单个纹理复制到图集纹理中。
///
/// # 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 (4 bytes per pixel) | RGBA像素数据每像素4字节
///
/// # Returns | 返回
/// Ok(()) on success, Err if texture not found or update failed
/// 成功时返回 Ok(()),纹理未找到或更新失败时返回 Err
pub fn update_texture_region(
&self,
id: u32,
x: u32,
y: u32,
width: u32,
height: u32,
pixels: &[u8],
) -> Result<()> {
let texture = self.textures.get(&id)
.ok_or_else(|| EngineError::TextureLoadFailed(format!("Texture {} not found", id)))?;
// Validate pixel data size
// 验证像素数据大小
let expected_size = (width * height * 4) as usize;
if pixels.len() != expected_size {
return Err(EngineError::TextureLoadFailed(format!(
"Pixel data size mismatch: expected {}, got {} | 像素数据大小不匹配:预期 {},实际 {}",
expected_size, pixels.len(), expected_size, pixels.len()
)));
}
self.gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(&texture.handle));
// Use texSubImage2D to update a region
// 使用 texSubImage2D 更新区域
self.gl.tex_sub_image_2d_with_i32_and_i32_and_u32_and_type_and_opt_u8_array(
WebGl2RenderingContext::TEXTURE_2D,
0,
x as i32,
y as i32,
width as i32,
height as i32,
WebGl2RenderingContext::RGBA,
WebGl2RenderingContext::UNSIGNED_BYTE,
Some(pixels),
).map_err(|e| EngineError::TextureLoadFailed(format!("texSubImage2D failed: {:?}", e)))?;
log::trace!("Updated texture {} region ({},{}) {}x{} | 更新纹理 {} 区域 ({},{}) {}x{}",
id, x, y, width, height, id, x, y, width, height);
Ok(())
}
}

View File

@@ -14,7 +14,8 @@ import {
GRAYSCALE_FRAGMENT_SHADER,
TINT_FRAGMENT_SHADER,
FLASH_FRAGMENT_SHADER,
OUTLINE_FRAGMENT_SHADER
OUTLINE_FRAGMENT_SHADER,
SHINY_FRAGMENT_SHADER
} from './Shader';
import { BuiltInMaterials, BuiltInShaders, UniformType } from './types';
import type { IAssetManager } from '@esengine/asset-system';
@@ -103,10 +104,67 @@ export class MaterialManager {
* Set the engine bridge for GPU operations.
* 设置用于GPU操作的引擎桥接。
*
* When set, uploads all built-in shaders to the GPU.
* 设置后将所有内置着色器上传到GPU。
*
* @param bridge - Engine bridge instance. | 引擎桥接实例。
*/
setEngineBridge(bridge: IEngineBridge): void {
this.engineBridge = bridge;
// Upload all existing shaders to the engine
// 将所有现有着色器上传到引擎
this.uploadShadersToEngine();
}
/**
* Upload all registered shaders to the engine.
* 将所有已注册的着色器上传到引擎。
*
* Called automatically when engine bridge is set.
* 设置引擎桥接时自动调用。
*/
private uploadShadersToEngine(): void {
if (!this.engineBridge) return;
let shadersUploaded = 0;
let materialsCreated = 0;
for (const [shaderId, shader] of this.shaders) {
// Skip if already compiled
// 跳过已编译的着色器
if (shader.compiled) continue;
try {
// Compile shader
// 编译着色器
this.engineBridge.compileShaderWithId(
shaderId,
shader.vertexSource,
shader.fragmentSource
);
shader.markCompiled();
shadersUploaded++;
logger.debug(`Uploaded shader ${shader.name} (ID: ${shaderId}) to engine`);
// Create a material for this shader if it doesn't exist in the engine
// 为此着色器创建材质(如果引擎中不存在)
// This allows sprites to reference the shader via materialId
// 这允许精灵通过 materialId 引用着色器
if (!this.engineBridge.hasMaterial(shaderId)) {
// Use shaderId as materialId for built-in shaders (1:1 mapping)
// 对于内置着色器,使用 shaderId 作为 materialId1:1 映射)
// BlendMode 1 = Alpha blending
this.engineBridge.createMaterialWithId(shaderId, shader.name, shaderId, 1);
materialsCreated++;
logger.debug(`Created material ${shader.name} (ID: ${shaderId}) for shader`);
}
} catch (e) {
logger.error(`Failed to upload shader ${shader.name} (ID: ${shaderId}):`, e);
}
}
logger.info(`Uploaded ${shadersUploaded} shaders and created ${materialsCreated} materials | 已上传 ${shadersUploaded} 个着色器,创建 ${materialsCreated} 个材质`);
}
/**
@@ -138,6 +196,7 @@ export class MaterialManager {
{ id: BuiltInShaders.Tint, name: 'Tint', vertex: DEFAULT_VERTEX_SHADER, fragment: TINT_FRAGMENT_SHADER },
{ id: BuiltInShaders.Flash, name: 'Flash', vertex: DEFAULT_VERTEX_SHADER, fragment: FLASH_FRAGMENT_SHADER },
{ id: BuiltInShaders.Outline, name: 'Outline', vertex: DEFAULT_VERTEX_SHADER, fragment: OUTLINE_FRAGMENT_SHADER },
{ id: BuiltInShaders.Shiny, name: 'Shiny', vertex: DEFAULT_VERTEX_SHADER, fragment: SHINY_FRAGMENT_SHADER },
];
for (const { id, name, vertex, fragment } of builtInShaders) {

View File

@@ -2,7 +2,13 @@
* MaterialSystemPlugin for ES Engine.
* ES引擎的材质系统插件。
*
* 注意:材质系统不注册独立组件,材质作为渲染组件(如 SpriteComponent的属性使用
* Provides:
* - Material and Shader management
* - Built-in shaders (Default, Grayscale, Tint, Flash, Outline, Shiny)
*
* 提供:
* - 材质和着色器管理
* - 内置着色器
*/
import { MaterialManager, getMaterialManager } from './MaterialManager';
@@ -82,7 +88,9 @@ const manifest: ModuleManifest = {
defaultEnabled: true,
isEngineModule: true,
dependencies: ['core', 'asset-system'],
exports: { other: ['Material', 'Shader', 'MaterialManager'] },
exports: {
other: ['Material', 'Shader', 'MaterialManager']
},
requiresWasm: false
};

View File

@@ -120,6 +120,13 @@ export class Shader {
/**
* Default sprite vertex shader source.
* 默认精灵顶点着色器源代码。
*
* Vertex layout (9 floats per vertex):
* 顶点布局(每顶点 9 个浮点数):
* - location 0: position (2 floats)
* - location 1: tex_coord (2 floats)
* - location 2: color (4 floats)
* - location 3: aspect_ratio (1 float)
*/
export const DEFAULT_VERTEX_SHADER = `#version 300 es
precision highp float;
@@ -128,6 +135,7 @@ precision highp float;
layout(location = 0) in vec2 a_position;
layout(location = 1) in vec2 a_texCoord;
layout(location = 2) in vec4 a_color;
layout(location = 3) in float a_aspectRatio;
// Uniforms | 统一变量
uniform mat3 u_projection;
@@ -135,6 +143,7 @@ uniform mat3 u_projection;
// Outputs to fragment shader | 输出到片段着色器
out vec2 v_texCoord;
out vec4 v_color;
out float v_aspectRatio;
void main() {
// Apply projection matrix | 应用投影矩阵
@@ -144,6 +153,7 @@ void main() {
// Pass through to fragment shader | 传递到片段着色器
v_texCoord = a_texCoord;
v_color = a_color;
v_aspectRatio = a_aspectRatio;
}
`;
@@ -157,6 +167,7 @@ precision highp float;
// Inputs from vertex shader | 来自顶点着色器的输入
in vec2 v_texCoord;
in vec4 v_color;
in float v_aspectRatio;
// Texture sampler | 纹理采样器
uniform sampler2D u_texture;
@@ -185,6 +196,7 @@ precision highp float;
in vec2 v_texCoord;
in vec4 v_color;
in float v_aspectRatio;
uniform sampler2D u_texture;
uniform float u_grayscale; // 0.0 = full color, 1.0 = full grayscale
@@ -215,6 +227,7 @@ precision highp float;
in vec2 v_texCoord;
in vec4 v_color;
in float v_aspectRatio;
uniform sampler2D u_texture;
uniform vec4 u_tintColor; // Tint color to apply
@@ -243,6 +256,7 @@ precision highp float;
in vec2 v_texCoord;
in vec4 v_color;
in float v_aspectRatio;
uniform sampler2D u_texture;
uniform vec4 u_flashColor; // Flash color
@@ -273,6 +287,7 @@ precision highp float;
in vec2 v_texCoord;
in vec4 v_color;
in float v_aspectRatio;
uniform sampler2D u_texture;
uniform vec4 u_outlineColor;
@@ -309,3 +324,98 @@ void main() {
}
}
`;
/**
* Shiny/Shimmer effect fragment shader.
* 闪光效果片段着色器。
*
* Uses v_aspectRatio from vertex attribute for aspect-ratio-aware rotation.
* 使用顶点属性中的 v_aspectRatio 进行宽高比感知的旋转。
*/
export const SHINY_FRAGMENT_SHADER = `#version 300 es
precision highp float;
in vec2 v_texCoord;
in vec4 v_color;
in float v_aspectRatio;
uniform sampler2D u_texture;
// Shiny effect uniforms | 闪光效果 uniform 变量
uniform float u_shinyProgress; // Animation progress (0-1) | 动画进度
uniform float u_shinyWidth; // Width of shine band (0-1) | 闪光带宽度
uniform float u_shinyRotation; // Rotation in radians | 旋转角度(弧度)
uniform float u_shinySoftness; // Edge softness (0-1) | 边缘柔和度
uniform float u_shinyBrightness; // Brightness multiplier | 亮度倍数
uniform float u_shinyGloss; // Gloss intensity (0=white, 1=color-tinted) | 光泽度
out vec4 fragColor;
void main() {
vec4 texColor = texture(u_texture, v_texCoord);
float originAlpha = texColor.a;
vec4 color = texColor * v_color;
// Early discard for transparent pixels
if (color.a < 0.01) {
discard;
}
// Calculate rotated position for the sweep (0 to 1 range)
// 计算旋转后的扫描位置0 到 1 范围)
//
// 1. 计算基础方向向量 dir = (cos(θ), sin(θ))
// 2. 宽高比校正dir.x *= height/width = 1/aspectRatio
// 3. 归一化方向向量
// 4. 计算扫描位置(考虑纹理坐标 Y 轴方向)
//
// 1. Calculate base direction vector dir = (cos(θ), sin(θ))
// 2. Aspect ratio correction: dir.x *= height/width = 1/aspectRatio
// 3. Normalize direction vector
// 4. Calculate sweep position (accounting for texture Y-axis direction)
//
vec2 center = v_texCoord - vec2(0.5);
float cosR = cos(u_shinyRotation);
float sinR = sin(u_shinyRotation);
// Aspect ratio correction: scale X by 1/aspectRatio (height/width)
// v_aspectRatio is passed from vertex attribute, calculated at render time
// 宽高比校正X 分量乘以 1/aspectRatio即 height/width
// v_aspectRatio 从顶点属性传入,在渲染时计算
float adjCosR = cosR / max(v_aspectRatio, 0.001);
// Normalize the direction vector
// 归一化方向向量
float len = sqrt(adjCosR * adjCosR + sinR * sinR);
float dirX = adjCosR / len;
float dirY = sinR / len;
// Sweep position: project onto perpendicular direction
// Y-axis flip: texture coords have Y pointing up, but we want top-to-bottom sweep
// 扫描位置:投影到垂直方向
// Y 轴翻转:纹理坐标 Y 向上,但我们需要从上到下扫描
float rotatedPos = (center.x * dirY - center.y * dirX) + 0.5;
// Map progress to location (-0.5 to 1.5 range for smooth entry/exit)
float location = u_shinyProgress * 2.0 - 0.5;
// Calculate normalized distance (1 at center, 0 at edges)
// 计算归一化距离中心为1边缘为0
float normalized = 1.0 - clamp(abs((rotatedPos - location) / max(u_shinyWidth, 0.001)), 0.0, 1.0);
// Apply softness with smoothstep
// 使用 smoothstep 应用柔和度
float shinePower = smoothstep(0.0, u_shinySoftness * 2.0, normalized);
// Calculate reflect color: lerp between white and bright original color
// 计算反射颜色:在白色和明亮的原色之间插值
vec3 reflectColor = mix(vec3(1.0), color.rgb * 10.0, u_shinyGloss);
// Apply shine: additive blend with halved intensity
// 应用高光:半强度加性混合
vec3 shineAdd = originAlpha * (shinePower * 0.5) * u_shinyBrightness * reflectColor;
vec3 finalColor = color.rgb + shineAdd;
fragColor = vec4(finalColor, color.a);
}
`;

View File

@@ -0,0 +1,189 @@
/**
* Base shiny effect component for ES Engine.
* ES引擎基础闪光效果组件。
*
* This abstract base class provides shared shiny effect properties and methods
* that can be extended by both SpriteShinyEffectComponent and UIShinyEffectComponent.
* 此抽象基类提供可由 SpriteShinyEffectComponent 和 UIShinyEffectComponent 扩展的
* 共享闪光效果属性和方法。
*
* @packageDocumentation
*/
/**
* Base interface for shiny effect configuration.
* 闪光效果配置的基础接口。
*
* This interface defines all properties needed for the shiny effect animation.
* 此接口定义了闪光效果动画所需的所有属性。
*/
export interface IShinyEffect {
// ============= Effect Parameters =============
// ============= 效果参数 =============
/**
* Width of the shiny band (0.0 - 1.0).
* 闪光带宽度 (0.0 - 1.0)。
*/
width: number;
/**
* Rotation angle in degrees.
* 旋转角度(度)。
*/
rotation: number;
/**
* Edge softness (0.0 - 1.0).
* 边缘柔和度 (0.0 - 1.0)。
*/
softness: number;
/**
* Brightness multiplier.
* 亮度倍增器。
*/
brightness: number;
/**
* Gloss intensity.
* 光泽度。
*/
gloss: number;
// ============= Animation Settings =============
// ============= 动画设置 =============
/**
* Whether the animation is playing.
* 动画是否正在播放。
*/
play: boolean;
/**
* Whether to loop the animation.
* 是否循环动画。
*/
loop: boolean;
/**
* Animation duration in seconds.
* 动画持续时间(秒)。
*/
duration: number;
/**
* Delay between loops in seconds.
* 循环之间的延迟(秒)。
*/
loopDelay: number;
/**
* Initial delay before first play in seconds.
* 首次播放前的初始延迟(秒)。
*/
initialDelay: number;
// ============= Runtime State =============
// ============= 运行时状态 =============
/** Current animation progress (0.0 - 1.0). | 当前动画进度。 */
progress: number;
/** Current elapsed time in the animation cycle. | 当前周期已用时间。 */
elapsedTime: number;
/** Whether currently in delay phase. | 是否处于延迟阶段。 */
inDelay: boolean;
/** Remaining delay time. | 剩余延迟时间。 */
delayRemaining: number;
/** Whether the initial delay has been processed. | 初始延迟是否已处理。 */
initialDelayProcessed: boolean;
}
/**
* Default values for shiny effect properties.
* 闪光效果属性的默认值。
*/
export const SHINY_EFFECT_DEFAULTS = {
width: 0.25,
rotation: 129,
softness: 1.0,
brightness: 1.0,
gloss: 1.0,
play: true,
loop: true,
duration: 2.0,
loopDelay: 2.0,
initialDelay: 0,
progress: 0,
elapsedTime: 0,
inDelay: false,
delayRemaining: 0,
initialDelayProcessed: false
} as const;
/**
* Property metadata for shiny effect Inspector.
* 闪光效果 Inspector 的属性元数据。
*/
export const SHINY_EFFECT_PROPERTIES = {
width: { type: 'number', label: 'Width', min: 0, max: 1, step: 0.01 },
rotation: { type: 'number', label: 'Rotation', min: 0, max: 360, step: 1 },
softness: { type: 'number', label: 'Softness', min: 0, max: 1, step: 0.01 },
brightness: { type: 'number', label: 'Brightness', min: 0, max: 2, step: 0.01 },
gloss: { type: 'number', label: 'Gloss', min: 0, max: 2, step: 0.01 },
play: { type: 'boolean', label: 'Play' },
loop: { type: 'boolean', label: 'Loop' },
duration: { type: 'number', label: 'Duration', min: 0.1, step: 0.1 },
loopDelay: { type: 'number', label: 'Loop Delay', min: 0, step: 0.1 },
initialDelay: { type: 'number', label: 'Initial Delay', min: 0, step: 0.1 }
} as const;
/**
* Reset shiny effect runtime state.
* 重置闪光效果运行时状态。
*
* @param effect - The shiny effect to reset | 要重置的闪光效果
*/
export function resetShinyEffect(effect: IShinyEffect): void {
effect.progress = 0;
effect.elapsedTime = 0;
effect.inDelay = false;
effect.delayRemaining = 0;
effect.initialDelayProcessed = false;
}
/**
* Start playing the shiny effect.
* 开始播放闪光效果。
*
* @param effect - The shiny effect to start | 要开始的闪光效果
*/
export function startShinyEffect(effect: IShinyEffect): void {
resetShinyEffect(effect);
effect.play = true;
}
/**
* Stop the shiny effect.
* 停止闪光效果。
*
* @param effect - The shiny effect to stop | 要停止的闪光效果
*/
export function stopShinyEffect(effect: IShinyEffect): void {
effect.play = false;
}
/**
* Get rotation in radians for shader use.
* 获取弧度制的旋转角度供着色器使用。
*
* @param effect - The shiny effect | 闪光效果
* @returns Rotation in radians | 弧度制的旋转角度
*/
export function getShinyRotationRadians(effect: IShinyEffect): number {
return effect.rotation * Math.PI / 180;
}

View File

@@ -0,0 +1,153 @@
/**
* Shiny effect animator for ES Engine.
* ES引擎闪光效果动画器。
*
* This module provides shared animation logic for shiny effects that can be used
* by both SpriteShinyEffectSystem and UIShinyEffectSystem.
* 此模块提供可由 SpriteShinyEffectSystem 和 UIShinyEffectSystem 使用的
* 共享闪光效果动画逻辑。
*
* @packageDocumentation
*/
import type { IShinyEffect } from './BaseShinyEffect';
import { getShinyRotationRadians } from './BaseShinyEffect';
import type { IMaterialOverridable } from '../interfaces/IMaterialOverridable';
import { BuiltInShaders } from '../types';
/**
* Shared animator logic for shiny effect.
* 闪光效果共享的动画逻辑。
*
* This class provides static methods for updating animation state and
* applying material overrides, eliminating code duplication between
* sprite and UI shiny effect systems.
* 此类提供用于更新动画状态和应用材质覆盖的静态方法,
* 消除精灵和 UI 闪光效果系统之间的代码重复。
*/
export class ShinyEffectAnimator {
/**
* Update animation state.
* 更新动画状态。
*
* This method handles:
* - Initial delay processing
* - Delay phase countdown
* - Progress calculation
* - Loop handling
*
* 此方法处理:
* - 初始延迟处理
* - 延迟阶段倒计时
* - 进度计算
* - 循环处理
*
* @param shiny - The shiny effect component | 闪光效果组件
* @param deltaTime - Time elapsed since last frame (seconds) | 上一帧以来经过的时间(秒)
*/
static updateAnimation(shiny: IShinyEffect, deltaTime: number): void {
// Handle initial delay
// 处理初始延迟
if (!shiny.initialDelayProcessed && shiny.initialDelay > 0) {
shiny.delayRemaining = shiny.initialDelay;
shiny.inDelay = true;
shiny.initialDelayProcessed = true;
}
// Handle delay phase
// 处理延迟阶段
if (shiny.inDelay) {
shiny.delayRemaining -= deltaTime;
if (shiny.delayRemaining <= 0) {
shiny.inDelay = false;
shiny.elapsedTime = 0;
}
return;
}
// Update elapsed time
// 更新已用时间
shiny.elapsedTime += deltaTime;
// Calculate progress (0 to 1)
// 计算进度0 到 1
shiny.progress = Math.min(shiny.elapsedTime / shiny.duration, 1.0);
// Check if animation completed
// 检查动画是否完成
if (shiny.progress >= 1.0) {
if (shiny.loop) {
// Start loop delay
// 开始循环延迟
shiny.inDelay = true;
shiny.delayRemaining = shiny.loopDelay;
shiny.progress = 0;
shiny.elapsedTime = 0;
} else {
// Stop animation
// 停止动画
shiny.play = false;
shiny.progress = 1.0;
}
}
}
/**
* Apply shiny effect material overrides to a renderable component.
* 将闪光效果材质覆盖应用到可渲染组件。
*
* This method:
* - Sets the Shiny shader if not already set
* - Applies all uniform overrides for the shiny effect
*
* Note: aspectRatio is passed via vertex attribute from the rendering pipeline,
* calculated from sprite's scaleX/scaleY in the Rust engine.
*
* 此方法:
* - 如果尚未设置,则设置 Shiny 着色器
* - 应用闪光效果的所有 uniform 覆盖
*
* 注意:宽高比通过渲染管线的顶点属性传递,在 Rust 引擎中从精灵的 scaleX/scaleY 计算。
*
* @param shiny - The shiny effect component | 闪光效果组件
* @param target - The target component implementing IMaterialOverridable | 实现 IMaterialOverridable 的目标组件
*/
static applyMaterialOverrides(shiny: IShinyEffect, target: IMaterialOverridable): void {
// Ensure target uses Shiny shader
// 确保目标使用 Shiny 着色器
if (target.getMaterialId() === 0) {
target.setMaterialId(BuiltInShaders.Shiny);
}
// Apply uniform overrides (aspectRatio is from vertex attribute v_aspectRatio)
// 应用 uniform 覆盖(宽高比来自顶点属性 v_aspectRatio
target.setOverrideFloat('u_shinyProgress', shiny.progress);
target.setOverrideFloat('u_shinyWidth', shiny.width);
target.setOverrideFloat('u_shinyRotation', getShinyRotationRadians(shiny));
target.setOverrideFloat('u_shinySoftness', shiny.softness);
target.setOverrideFloat('u_shinyBrightness', shiny.brightness);
target.setOverrideFloat('u_shinyGloss', shiny.gloss);
}
/**
* Process a single entity with shiny effect.
* 处理单个带有闪光效果的实体。
*
* This is a convenience method that combines updateAnimation and applyMaterialOverrides.
* 这是一个结合了 updateAnimation 和 applyMaterialOverrides 的便捷方法。
*
* @param shiny - The shiny effect component | 闪光效果组件
* @param target - The target component implementing IMaterialOverridable | 实现 IMaterialOverridable 的目标组件
* @param deltaTime - Time elapsed since last frame (seconds) | 上一帧以来经过的时间(秒)
* @returns True if the effect was processed, false if skipped | 如果效果已处理则返回 true如果跳过则返回 false
*/
static processEffect(shiny: IShinyEffect, target: IMaterialOverridable, deltaTime: number): boolean {
if (!shiny.play) {
return false;
}
this.updateAnimation(shiny, deltaTime);
this.applyMaterialOverrides(shiny, target);
return true;
}
}

View File

@@ -25,6 +25,46 @@
// 类型。
export * from './types';
// Interfaces.
// 接口。
export type {
MaterialPropertyType,
MaterialPropertyOverride,
MaterialOverrides,
IMaterialOverridable
} from './interfaces/IMaterialOverridable';
export type {
ShaderPropertyType,
ShaderPropertyHint,
ShaderPropertyMeta,
ShaderAssetDefinition,
ShaderAssetFile
} from './interfaces/IShaderProperty';
export {
BUILTIN_SHADER_PROPERTIES,
getShaderProperties,
getShaderPropertiesById
} from './interfaces/IShaderProperty';
// Mixins.
// Mixin。
export { MaterialOverridableMixin, MaterialOverrideHelper } from './mixins/MaterialOverridableMixin';
// Effects.
// 效果。
export type { IShinyEffect } from './effects/BaseShinyEffect';
export {
SHINY_EFFECT_DEFAULTS,
SHINY_EFFECT_PROPERTIES,
resetShinyEffect,
startShinyEffect,
stopShinyEffect,
getShinyRotationRadians
} from './effects/BaseShinyEffect';
export { ShinyEffectAnimator } from './effects/ShinyEffectAnimator';
// Core classes.
// 核心类。
export { Material } from './Material';
@@ -35,7 +75,8 @@ export {
GRAYSCALE_FRAGMENT_SHADER,
TINT_FRAGMENT_SHADER,
FLASH_FRAGMENT_SHADER,
OUTLINE_FRAGMENT_SHADER
OUTLINE_FRAGMENT_SHADER,
SHINY_FRAGMENT_SHADER
} from './Shader';
// Manager.

View File

@@ -0,0 +1,176 @@
/**
* Material override interfaces for ES Engine.
* ES引擎材质覆盖接口。
*
* This module provides a unified interface for components that support
* material property overrides (SpriteComponent, UIRenderComponent, etc.).
* 此模块为支持材质属性覆盖的组件提供统一接口。
*
* @packageDocumentation
*/
/**
* Material property override value types.
* 材质属性覆盖值类型。
*/
export type MaterialPropertyType = 'float' | 'vec2' | 'vec3' | 'vec4' | 'color' | 'int';
/**
* Material property override definition.
* 材质属性覆盖定义。
*/
export interface MaterialPropertyOverride {
/** Property type | 属性类型 */
type: MaterialPropertyType;
/** Property value | 属性值 */
value: number | number[];
}
/**
* Material overrides record type.
* 材质覆盖记录类型。
*/
export type MaterialOverrides = Record<string, MaterialPropertyOverride>;
/**
* Interface for components that support material property overrides.
* 支持材质属性覆盖的组件接口。
*
* Both SpriteComponent and UIRenderComponent implement this interface,
* allowing unified handling by material systems and inspectors.
* SpriteComponent 和 UIRenderComponent 都实现此接口,
* 允许材质系统和检查器统一处理。
*
* @example
* ```typescript
* function applyShinyEffect(target: IMaterialOverridable, progress: number): void {
* target.setMaterialId(BuiltInShaders.Shiny);
* target.setOverrideFloat('u_shinyProgress', progress);
* }
*
* // Works with both SpriteComponent and UIRenderComponent
* applyShinyEffect(spriteComponent, 0.5);
* applyShinyEffect(uiRenderComponent, 0.5);
* ```
*/
export interface IMaterialOverridable {
/**
* Material GUID for asset reference.
* 材质资产引用的 GUID。
*/
materialGuid: string;
/**
* Current material overrides (read-only access).
* 当前材质覆盖(只读访问)。
*/
readonly materialOverrides: MaterialOverrides;
/**
* Get current material ID.
* 获取当前材质 ID。
*/
getMaterialId(): number;
/**
* Set material ID.
* 设置材质 ID。
*
* @param id - Material/Shader ID from BuiltInShaders or custom shader
* 来自 BuiltInShaders 或自定义着色器的材质/着色器 ID
*/
setMaterialId(id: number): void;
/**
* Set a float uniform override.
* 设置浮点 uniform 覆盖。
*
* @param name - Uniform name (e.g., 'u_shinyProgress')
* @param value - Float value
*/
setOverrideFloat(name: string, value: number): this;
/**
* Set a vec2 uniform override.
* 设置 vec2 uniform 覆盖。
*
* @param name - Uniform name
* @param x - X component
* @param y - Y component
*/
setOverrideVec2(name: string, x: number, y: number): this;
/**
* Set a vec3 uniform override.
* 设置 vec3 uniform 覆盖。
*
* @param name - Uniform name
* @param x - X component
* @param y - Y component
* @param z - Z component
*/
setOverrideVec3(name: string, x: number, y: number, z: number): this;
/**
* Set a vec4 uniform override.
* 设置 vec4 uniform 覆盖。
*
* @param name - Uniform name
* @param x - X component
* @param y - Y component
* @param z - Z component
* @param w - W component
*/
setOverrideVec4(name: string, x: number, y: number, z: number, w: number): this;
/**
* Set a color uniform override (RGBA, 0.0-1.0).
* 设置颜色 uniform 覆盖RGBA0.0-1.0)。
*
* @param name - Uniform name
* @param r - Red component (0-1)
* @param g - Green component (0-1)
* @param b - Blue component (0-1)
* @param a - Alpha component (0-1), defaults to 1.0
*/
setOverrideColor(name: string, r: number, g: number, b: number, a?: number): this;
/**
* Set an integer uniform override.
* 设置整数 uniform 覆盖。
*
* @param name - Uniform name
* @param value - Integer value
*/
setOverrideInt(name: string, value: number): this;
/**
* Get a specific override value.
* 获取特定覆盖值。
*
* @param name - Uniform name
* @returns Override value or undefined if not set
*/
getOverride(name: string): MaterialPropertyOverride | undefined;
/**
* Remove a specific override.
* 移除特定覆盖。
*
* @param name - Uniform name to remove
*/
removeOverride(name: string): this;
/**
* Clear all overrides.
* 清除所有覆盖。
*/
clearOverrides(): this;
/**
* Check if any overrides are set.
* 检查是否设置了任何覆盖。
*/
hasOverrides(): boolean;
}

View File

@@ -0,0 +1,369 @@
/**
* Shader property interfaces for ES Engine.
* ES引擎着色器属性接口。
*
* This module provides interfaces for defining shader property metadata,
* enabling automatic Inspector UI generation for material editing.
* 此模块提供用于定义着色器属性元数据的接口,
* 实现材质编辑的自动 Inspector UI 生成。
*
* @packageDocumentation
*/
/**
* Shader property types.
* 着色器属性类型。
*/
export type ShaderPropertyType =
| 'float'
| 'int'
| 'vec2'
| 'vec3'
| 'vec4'
| 'color'
| 'texture';
/**
* UI hint for property display.
* 属性显示的 UI 提示。
*/
export type ShaderPropertyHint =
| 'range' // Show as slider | 显示为滑块
| 'angle' // Show as angle picker (degrees) | 显示为角度选择器(度)
| 'hdr' // HDR color picker | HDR 颜色选择器
| 'normal' // Normal map preview | 法线贴图预览
| 'default'; // Default input | 默认输入
/**
* Shader property UI metadata.
* 着色器属性 UI 元数据。
*
* This interface defines all metadata needed to generate an Inspector UI
* for editing shader uniform values.
* 此接口定义生成用于编辑着色器 uniform 值的 Inspector UI 所需的所有元数据。
*/
export interface ShaderPropertyMeta {
/**
* Property type.
* 属性类型。
*/
type: ShaderPropertyType;
/**
* Display label (supports i18n key format "中文 | English").
* 显示标签(支持国际化键格式 "中文 | English")。
*/
label: string;
/**
* Property group for organization in Inspector.
* Inspector 中用于组织的属性分组。
*
* Properties with the same group will be displayed together under a collapsible header.
* 具有相同分组的属性将在可折叠标题下一起显示。
*/
group?: string;
/**
* Default value.
* 默认值。
*/
default?: number | number[] | string;
/**
* Minimum value (for numeric types).
* 最小值(用于数值类型)。
*/
min?: number;
/**
* Maximum value (for numeric types).
* 最大值(用于数值类型)。
*/
max?: number;
/**
* Step value for numeric inputs.
* 数值输入的步长值。
*/
step?: number;
/**
* UI hint for specialized display.
* 用于特殊显示的 UI 提示。
*/
hint?: ShaderPropertyHint;
/**
* Tooltip description (supports i18n).
* 工具提示描述(支持国际化)。
*/
tooltip?: string;
/**
* Whether to hide in Inspector.
* 是否在 Inspector 中隐藏。
*
* Hidden properties are typically controlled by scripts or systems.
* 隐藏的属性通常由脚本或系统控制。
*/
hidden?: boolean;
/**
* Texture filter options (for texture type).
* 纹理过滤选项(用于纹理类型)。
*/
textureFilter?: 'linear' | 'nearest';
/**
* Texture wrap options (for texture type).
* 纹理包裹选项(用于纹理类型)。
*/
textureWrap?: 'repeat' | 'clamp' | 'mirror';
}
/**
* Extended shader definition with property metadata.
* 带属性元数据的扩展着色器定义。
*
* This interface extends the basic shader definition with UI metadata
* for Inspector generation and asset serialization.
* 此接口使用 UI 元数据扩展基本着色器定义,
* 用于 Inspector 生成和资产序列化。
*/
export interface ShaderAssetDefinition {
/**
* Shader name (unique identifier).
* 着色器名称(唯一标识符)。
*/
name: string;
/**
* Display name for UI.
* UI 显示名称。
*/
displayName?: string;
/**
* Shader description.
* 着色器描述。
*/
description?: string;
/**
* Vertex shader source (inline GLSL or relative path).
* 顶点着色器源(内联 GLSL 或相对路径)。
*/
vertexSource: string;
/**
* Fragment shader source (inline GLSL or relative path).
* 片段着色器源(内联 GLSL 或相对路径)。
*/
fragmentSource: string;
/**
* Property metadata for Inspector.
* Inspector 属性元数据。
*
* Key is the uniform name (e.g., 'u_shinyProgress').
* 键是 uniform 名称(例如 'u_shinyProgress')。
*/
properties?: Record<string, ShaderPropertyMeta>;
/**
* Render queue / order.
* 渲染队列/顺序。
*
* Lower values render first. Default is 2000 (opaque).
* 较低的值先渲染。默认为 2000不透明
*/
renderQueue?: number;
/**
* Preset blend mode.
* 预设混合模式。
*/
blendMode?: 'alpha' | 'additive' | 'multiply' | 'opaque';
/**
* Whether this shader requires depth testing.
* 此着色器是否需要深度测试。
*/
depthTest?: boolean;
/**
* Whether this shader writes to depth buffer.
* 此着色器是否写入深度缓冲区。
*/
depthWrite?: boolean;
}
/**
* Shader asset file format (.shader).
* 着色器资产文件格式 (.shader)。
*/
export interface ShaderAssetFile {
/**
* Schema version for format evolution.
* 用于格式演进的模式版本。
*/
version: number;
/**
* Shader definition.
* 着色器定义。
*/
shader: ShaderAssetDefinition;
}
/**
* Built-in shader property definitions.
* 内置着色器属性定义。
*
* These are the property metadata for built-in shaders.
* 这些是内置着色器的属性元数据。
*/
export const BUILTIN_SHADER_PROPERTIES: Record<string, Record<string, ShaderPropertyMeta>> = {
Shiny: {
u_shinyProgress: {
type: 'float',
label: '进度 | Progress',
group: 'Animation',
default: 0,
min: 0,
max: 1,
step: 0.01,
hidden: true
},
u_shinyWidth: {
type: 'float',
label: '宽度 | Width',
group: 'Effect',
default: 0.25,
min: 0,
max: 1,
step: 0.01,
tooltip: '闪光带宽度 | Width of the shiny band'
},
u_shinyRotation: {
type: 'float',
label: '角度 | Rotation',
group: 'Effect',
default: 0.524, // 30 degrees in radians | 30度的弧度值
min: 0,
max: 6.28, // 360 degrees | 360度
step: 0.01,
hint: 'angle',
tooltip: '闪光扫过的角度 | Angle of shine sweep'
},
u_shinySoftness: {
type: 'float',
label: '柔和度 | Softness',
group: 'Effect',
default: 1.0,
min: 0,
max: 1,
step: 0.01
},
u_shinyBrightness: {
type: 'float',
label: '亮度 | Brightness',
group: 'Effect',
default: 1.0,
min: 0,
max: 2,
step: 0.01
},
u_shinyGloss: {
type: 'float',
label: '光泽度 | Gloss',
group: 'Effect',
default: 1.0,
min: 0,
max: 1,
step: 0.01,
tooltip: '0=白色高光, 1=带颜色 | 0=white shine, 1=color-tinted'
}
},
Grayscale: {
u_grayscale: {
type: 'float',
label: '灰度 | Grayscale',
default: 1.0,
min: 0,
max: 1,
step: 0.01,
hint: 'range',
tooltip: '0=彩色, 1=完全灰度 | 0=full color, 1=full grayscale'
}
},
Tint: {
u_tintColor: {
type: 'color',
label: '着色 | Tint Color',
default: [1, 1, 1, 1]
}
},
Flash: {
u_flashColor: {
type: 'color',
label: '闪光颜色 | Flash Color',
default: [1, 1, 1, 1]
},
u_flashAmount: {
type: 'float',
label: '闪光强度 | Flash Amount',
default: 0,
min: 0,
max: 1,
step: 0.01,
hint: 'range'
}
},
Outline: {
u_outlineColor: {
type: 'color',
label: '描边颜色 | Outline Color',
default: [0, 0, 0, 1]
},
u_outlineWidth: {
type: 'float',
label: '描边宽度 | Outline Width',
default: 1,
min: 0,
max: 10,
step: 0.5
},
u_texelSize: {
type: 'vec2',
label: '纹素大小 | Texel Size',
default: [0.01, 0.01],
hidden: true
}
}
};
/**
* Get shader property metadata by shader name.
* 通过着色器名称获取着色器属性元数据。
*
* @param shaderName - Name of the shader | 着色器名称
* @returns Property metadata or undefined | 属性元数据或 undefined
*/
export function getShaderProperties(shaderName: string): Record<string, ShaderPropertyMeta> | undefined {
return BUILTIN_SHADER_PROPERTIES[shaderName];
}
/**
* Get shader property metadata by shader ID.
* 通过着色器 ID 获取着色器属性元数据。
*
* @param shaderId - ID of the shader (from BuiltInShaders) | 着色器 ID来自 BuiltInShaders
* @returns Property metadata or undefined | 属性元数据或 undefined
*/
export function getShaderPropertiesById(shaderId: number): Record<string, ShaderPropertyMeta> | undefined {
const shaderNames = ['DefaultSprite', 'Grayscale', 'Tint', 'Flash', 'Outline', 'Shiny'];
const name = shaderNames[shaderId];
return name ? BUILTIN_SHADER_PROPERTIES[name] : undefined;
}

View File

@@ -0,0 +1,268 @@
/**
* Material overridable mixin for ES Engine.
* ES引擎材质覆盖 Mixin。
*
* This mixin provides material override functionality that can be mixed into
* any component class (SpriteComponent, UIRenderComponent, etc.).
* 此 Mixin 提供材质覆盖功能,可混入任何组件类。
*
* @packageDocumentation
*/
import type {
MaterialPropertyOverride,
MaterialOverrides,
IMaterialOverridable
} from '../interfaces/IMaterialOverridable';
/**
* Constructor type for mixin base class.
* Mixin 基类的构造函数类型。
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Constructor<T = object> = new (...args: any[]) => T;
/**
* Mixin that provides material override functionality.
* 提供材质覆盖功能的 Mixin。
*
* This mixin adds all material override methods to a base class,
* implementing the IMaterialOverridable interface.
* 此 Mixin 将所有材质覆盖方法添加到基类,实现 IMaterialOverridable 接口。
*
* @example
* ```typescript
* // Apply mixin to a component class
* class MySpriteComponent extends MaterialOverridableMixin(Component) {
* // ... other properties
* }
*
* // The class now has all material override methods
* const sprite = new MySpriteComponent();
* sprite.setMaterialId(BuiltInShaders.Shiny);
* sprite.setOverrideFloat('u_shinyProgress', 0.5);
* ```
*
* @param Base - Base class to extend
* @returns Class with material override functionality
*/
export function MaterialOverridableMixin<TBase extends Constructor>(Base: TBase) {
return class MaterialOverridableClass extends Base implements IMaterialOverridable {
/**
* Material GUID for asset reference.
* 材质资产引用的 GUID。
*/
materialGuid: string = '';
/**
* Current material ID.
* 当前材质 ID。
* @internal - Use getMaterialId() and setMaterialId() instead
*/
__materialId: number = 0;
/**
* Material property overrides.
* 材质属性覆盖。
* @internal - Use materialOverrides getter instead
*/
__materialOverrides: MaterialOverrides = {};
/**
* Get current material overrides.
* 获取当前材质覆盖。
*/
get materialOverrides(): MaterialOverrides {
return this.__materialOverrides;
}
/**
* Get current material ID.
* 获取当前材质 ID。
*/
getMaterialId(): number {
return this.__materialId;
}
/**
* Set material ID.
* 设置材质 ID。
*/
setMaterialId(id: number): void {
this.__materialId = id;
}
/**
* Set a float uniform override.
* 设置浮点 uniform 覆盖。
*/
setOverrideFloat(name: string, value: number): this {
this.__materialOverrides[name] = { type: 'float', value };
return this;
}
/**
* Set a vec2 uniform override.
* 设置 vec2 uniform 覆盖。
*/
setOverrideVec2(name: string, x: number, y: number): this {
this.__materialOverrides[name] = { type: 'vec2', value: [x, y] };
return this;
}
/**
* Set a vec3 uniform override.
* 设置 vec3 uniform 覆盖。
*/
setOverrideVec3(name: string, x: number, y: number, z: number): this {
this.__materialOverrides[name] = { type: 'vec3', value: [x, y, z] };
return this;
}
/**
* Set a vec4 uniform override.
* 设置 vec4 uniform 覆盖。
*/
setOverrideVec4(name: string, x: number, y: number, z: number, w: number): this {
this.__materialOverrides[name] = { type: 'vec4', value: [x, y, z, w] };
return this;
}
/**
* Set a color uniform override (RGBA, 0.0-1.0).
* 设置颜色 uniform 覆盖RGBA0.0-1.0)。
*/
setOverrideColor(name: string, r: number, g: number, b: number, a: number = 1.0): this {
this.__materialOverrides[name] = { type: 'color', value: [r, g, b, a] };
return this;
}
/**
* Set an integer uniform override.
* 设置整数 uniform 覆盖。
*/
setOverrideInt(name: string, value: number): this {
this.__materialOverrides[name] = { type: 'int', value: Math.floor(value) };
return this;
}
/**
* Get a specific override value.
* 获取特定覆盖值。
*/
getOverride(name: string): MaterialPropertyOverride | undefined {
return this.__materialOverrides[name];
}
/**
* Remove a specific override.
* 移除特定覆盖。
*/
removeOverride(name: string): this {
delete this.__materialOverrides[name];
return this;
}
/**
* Clear all overrides.
* 清除所有覆盖。
*/
clearOverrides(): this {
this.__materialOverrides = {};
return this;
}
/**
* Check if any overrides are set.
* 检查是否设置了任何覆盖。
*/
hasOverrides(): boolean {
return Object.keys(this.__materialOverrides).length > 0;
}
};
}
/**
* Helper class that can be used for composition instead of mixin.
* 可用于组合而非 Mixin 的辅助类。
*
* Use this when you cannot use mixins (e.g., class already extends another class).
* 当无法使用 Mixin 时使用此类(例如,类已继承其他类)。
*
* @example
* ```typescript
* class MyComponent extends Component {
* private _materialHelper = new MaterialOverrideHelper();
*
* get materialOverrides() { return this._materialHelper.materialOverrides; }
* getMaterialId() { return this._materialHelper.getMaterialId(); }
* setMaterialId(id: number) { this._materialHelper.setMaterialId(id); }
* // ... delegate other methods
* }
* ```
*/
export class MaterialOverrideHelper implements IMaterialOverridable {
materialGuid: string = '';
private _materialId: number = 0;
private _materialOverrides: MaterialOverrides = {};
get materialOverrides(): MaterialOverrides {
return this._materialOverrides;
}
getMaterialId(): number {
return this._materialId;
}
setMaterialId(id: number): void {
this._materialId = id;
}
setOverrideFloat(name: string, value: number): this {
this._materialOverrides[name] = { type: 'float', value };
return this;
}
setOverrideVec2(name: string, x: number, y: number): this {
this._materialOverrides[name] = { type: 'vec2', value: [x, y] };
return this;
}
setOverrideVec3(name: string, x: number, y: number, z: number): this {
this._materialOverrides[name] = { type: 'vec3', value: [x, y, z] };
return this;
}
setOverrideVec4(name: string, x: number, y: number, z: number, w: number): this {
this._materialOverrides[name] = { type: 'vec4', value: [x, y, z, w] };
return this;
}
setOverrideColor(name: string, r: number, g: number, b: number, a: number = 1.0): this {
this._materialOverrides[name] = { type: 'color', value: [r, g, b, a] };
return this;
}
setOverrideInt(name: string, value: number): this {
this._materialOverrides[name] = { type: 'int', value: Math.floor(value) };
return this;
}
getOverride(name: string): MaterialPropertyOverride | undefined {
return this._materialOverrides[name];
}
removeOverride(name: string): this {
delete this._materialOverrides[name];
return this;
}
clearOverrides(): this {
this._materialOverrides = {};
return this;
}
hasOverrides(): boolean {
return Object.keys(this._materialOverrides).length > 0;
}
}

View File

@@ -125,7 +125,8 @@ export const BuiltInShaders = {
Grayscale: 1,
Tint: 2,
Flash: 3,
Outline: 4
Outline: 4,
Shiny: 5
} as const;
/**

View File

@@ -2,11 +2,14 @@ import { Vector2 } from './Vector2';
/**
* 3x3变换矩阵类
* 3x3 Transform Matrix Class
*
* 用于2D变换平移、旋转、缩放的3x3矩阵
* 矩阵布局:
* [m00, m01, m02] [scaleX * cos, -scaleY * sin, translateX]
* [m10, m11, m12] = [scaleX * sin, scaleY * cos, translateY]
* 使用左手坐标系(顺时针正旋转)
*
* 矩阵布局(顺时针旋转):
* [m00, m01, m02] [scaleX * cos, scaleY * sin, translateX]
* [m10, m11, m12] = [-scaleX * sin, scaleY * cos, translateY]
* [m20, m21, m22] [0, 0, 1]
*/
export class Matrix3 {
@@ -243,7 +246,12 @@ export class Matrix3 {
}
/**
* 设置为旋转矩阵
* 设置为旋转矩阵(顺时针为正)
* Set as rotation matrix (clockwise positive)
*
* 使用左手坐标系约定:正角度 = 顺时针旋转
* Uses left-hand coordinate system: positive angle = clockwise
*
* @param angle 旋转角度(弧度)
* @returns 当前矩阵实例(链式调用)
*/
@@ -251,9 +259,11 @@ export class Matrix3 {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
// Clockwise rotation matrix
// 顺时针旋转矩阵
this.elements.set([
cos, -sin, 0,
sin, cos, 0,
cos, sin, 0,
-sin, cos, 0,
0, 0, 1
]);
return this;
@@ -287,7 +297,12 @@ export class Matrix3 {
}
/**
* 复合旋转
* 复合旋转(顺时针为正)
* Composite rotation (clockwise positive)
*
* 使用左手坐标系约定:正角度 = 顺时针旋转
* Uses left-hand coordinate system: positive angle = clockwise
*
* @param angle 旋转角度(弧度)
* @returns 当前矩阵实例(链式调用)
*/
@@ -295,10 +310,12 @@ export class Matrix3 {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const m00 = this.m00 * cos + this.m01 * sin;
const m01 = this.m00 * -sin + this.m01 * cos;
const m10 = this.m10 * cos + this.m11 * sin;
const m11 = this.m10 * -sin + this.m11 * cos;
// Clockwise rotation: multiply by [cos, sin; -sin, cos]
// 顺时针旋转
const m00 = this.m00 * cos - this.m01 * sin;
const m01 = this.m00 * sin + this.m01 * cos;
const m10 = this.m10 * cos - this.m11 * sin;
const m11 = this.m10 * sin + this.m11 * cos;
this.m00 = m00;
this.m01 = m01;
@@ -433,11 +450,15 @@ export class Matrix3 {
}
/**
* 获取旋转角度
* 获取旋转角度(顺时针为正)
* Get rotation angle (clockwise positive)
* @returns 旋转角度(弧度)
*/
getRotation(): number {
return Math.atan2(this.m10, this.m00);
// For clockwise rotation matrix [cos, sin; -sin, cos]
// m00 = cos, m01 = sin, so atan2(m01, m00) = θ
// 顺时针旋转矩阵:从 m01 和 m00 提取角度
return Math.atan2(this.m01, this.m00);
}
/**
@@ -551,7 +572,12 @@ export class Matrix3 {
}
/**
* 创建TRS平移-旋转-缩放)变换矩阵
* 创建TRS平移-旋转-缩放)变换矩阵(顺时针为正)
* Create TRS (Translate-Rotate-Scale) matrix (clockwise positive)
*
* 使用左手坐标系约定:正角度 = 顺时针旋转
* Uses left-hand coordinate system: positive angle = clockwise
*
* @param translation 平移向量
* @param rotation 旋转角度(弧度)
* @param scale 缩放向量
@@ -561,9 +587,11 @@ export class Matrix3 {
const cos = Math.cos(rotation);
const sin = Math.sin(rotation);
// Clockwise rotation matrix with scale
// 带缩放的顺时针旋转矩阵
return new Matrix3([
scale.x * cos, -scale.y * sin, translation.x,
scale.x * sin, scale.y * cos, translation.y,
scale.x * cos, scale.y * sin, translation.x,
-scale.x * sin, scale.y * cos, translation.y,
0, 0, 1
]);
}

View File

@@ -282,25 +282,35 @@ export class Vector2 implements IVector2 {
}
/**
* 获取垂直向量(时针旋转90度
* 获取垂直向量(时针旋转90度
* Get perpendicular vector (clockwise 90 degrees)
* @returns 新的垂直向量
*/
perpendicular(): Vector2 {
return new Vector2(-this.y, this.x);
// Clockwise 90° rotation: (x, y) -> (y, -x)
// 顺时针旋转 90°
return new Vector2(this.y, -this.x);
}
// 变换操作
/**
* 向量旋转
* 向量旋转(顺时针为正)
* Rotate vector (clockwise positive)
*
* 使用左手坐标系约定:正角度 = 顺时针旋转
* Uses left-hand coordinate system: positive angle = clockwise
*
* @param angle 旋转角度(弧度)
* @returns 当前向量实例(链式调用)
*/
rotate(angle: number): this {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const x = this.x * cos - this.y * sin;
const y = this.x * sin + this.y * cos;
// Clockwise rotation: x' = x*cos + y*sin, y' = -x*sin + y*cos
// 顺时针旋转公式
const x = this.x * cos + this.y * sin;
const y = -this.x * sin + this.y * cos;
this.x = x;
this.y = y;
return this;

View File

@@ -149,11 +149,13 @@ describe('Vector2', () => {
});
describe('变换操作', () => {
test('rotate方法应正确旋转向量', () => {
test('rotate方法应正确旋转向量(顺时针)', () => {
// Clockwise rotation: (1, 0) rotated 90° clockwise = (0, -1)
// 顺时针旋转:(1, 0) 顺时针旋转 90° = (0, -1)
const v = new Vector2(1, 0);
v.rotate(Math.PI / 2);
expectFloatsEqual(v.x, 0, 1e-10);
expectFloatsEqual(v.y, 1, 1e-10);
expectFloatsEqual(v.y, -1, 1e-10);
});
test('reflect方法应正确反射向量', () => {

View File

@@ -148,7 +148,10 @@ function particleSystemGizmoProvider(
const shapeWidth = (asset?.shapeWidth ?? 0) * scaleX;
const shapeHeight = (asset?.shapeHeight ?? 0) * scaleY;
const shapeAngle = (asset?.shapeAngle ?? 30) * Math.PI / 180; // 转换为弧度
const direction = ((asset?.direction ?? 90) * Math.PI / 180) + worldRotation; // 转换为弧度并应用世界旋转
// 转换为弧度并应用世界旋转 | Convert to radians and apply world rotation
// worldRotation 是度(顺时针),转为弧度(逆时针)用于数学计算
// worldRotation is degrees(clockwise), convert to radians(counter-clockwise) for math
const direction = ((asset?.direction ?? 90) * Math.PI / 180) - (worldRotation * Math.PI / 180);
// 根据发射形状绘制 Gizmo | Draw gizmo based on emission shape
switch (emissionShape) {

View File

@@ -227,6 +227,12 @@ export class ClickFxSystem extends EntitySystem {
private _checkTrigger(clickFx: ClickFxComponent): boolean {
const mode = clickFx.triggerMode;
// 首先检查鼠标是否在 Canvas 内
// First check if mouse is within canvas bounds
if (!this._isMouseInCanvas()) {
return false;
}
switch (mode) {
case ClickFxTriggerMode.LeftClick:
return Input.isMouseButtonJustPressed(MouseButton.Left);
@@ -253,6 +259,27 @@ export class ClickFxSystem extends EntitySystem {
}
}
/**
* 检查鼠标是否在 Canvas 内
* Check if mouse is within canvas bounds
*/
private _isMouseInCanvas(): boolean {
if (!this._canvas) {
return true; // 没有 canvas 引用时,默认允许(兼容旧行为)
}
const rect = this._canvas.getBoundingClientRect();
const mouseX = Input.mousePosition.x;
const mouseY = Input.mousePosition.y;
// 检查鼠标是否在 canvas 边界内
// Check if mouse is within canvas bounds
return mouseX >= rect.left &&
mouseX <= rect.right &&
mouseY >= rect.top &&
mouseY <= rect.bottom;
}
/**
* 检查是否有新的触摸开始
* Check if there's a new touch start

View File

@@ -12,6 +12,12 @@ import type { IParticleAsset } from '../loaders/ParticleLoader';
*/
const DEFAULT_PARTICLE_TEXTURE_ID = 99999;
/**
* 角度转换常量
* Angle conversion constants
*/
const DEG_TO_RAD = Math.PI / 180;
/**
* 生成默认粒子纹理的 Data URL渐变圆形
* Generate default particle texture Data URL (gradient circle)
@@ -171,9 +177,10 @@ export class ParticleUpdateSystem extends EntitySystem {
worldY = pos.y;
// 获取旋转2D 使用 z 分量)| Get rotation (2D uses z component)
// 转换:度(顺时针) → 弧度(逆时针) | Convert: degrees(clockwise) → radians(counter-clockwise)
const rot = transform.worldRotation ?? transform.rotation;
if (rot) {
worldRotation = rot.z;
worldRotation = -rot.z * DEG_TO_RAD;
}
// 获取缩放 | Get scale

View File

@@ -27,6 +27,9 @@ import {
RigidbodyType2D
} from '@esengine/physics-rapier2d';
/** 度转弧度常量 | Degrees to radians constant */
const DEG_TO_RAD = Math.PI / 180;
/**
* 物理 Gizmo 颜色配置
*/
@@ -185,17 +188,21 @@ function circleCollider2DGizmoProvider(
gizmos.push(...createCenterMarkGizmo(worldX, worldY, centerMarkSize, PhysicsGizmoColors.centerMark));
// 半径指示线 (从中心到右边缘)
const rotation = typeof transform.rotation === 'number'
// Radius indicator line (from center to right edge)
const rotationDeg = typeof transform.rotation === 'number'
? transform.rotation
: transform.rotation.z;
const cos = Math.cos(rotation);
const sin = Math.sin(rotation);
const rotationRad = rotationDeg * DEG_TO_RAD;
const cos = Math.cos(rotationRad);
const sin = Math.sin(rotationRad);
// Clockwise rotation: use (cos, -sin) for direction
// 顺时针旋转:使用 (cos, -sin) 表示方向
gizmos.push({
type: 'line',
points: [
{ x: worldX, y: worldY },
{ x: worldX + scaledRadius * cos, y: worldY + scaledRadius * sin }
{ x: worldX + scaledRadius * cos, y: worldY - scaledRadius * sin }
],
color: PhysicsGizmoColors.selected,
closed: false
@@ -205,7 +212,7 @@ function circleCollider2DGizmoProvider(
gizmos.push({
type: 'circle',
x: worldX + scaledRadius * cos,
y: worldY + scaledRadius * sin,
y: worldY - scaledRadius * sin,
radius: scaledRadius * 0.08,
color: PhysicsGizmoColors.selected
} as ICircleGizmoData);
@@ -276,21 +283,24 @@ function polygonCollider2DGizmoProvider(
const gizmos: IGizmoRenderData[] = [];
const color = getColliderColor(isSelected, collider.isTrigger);
const rotation = typeof transform.rotation === 'number'
const rotationDeg = typeof transform.rotation === 'number'
? transform.rotation
: transform.rotation.z;
const totalRotation = rotation + collider.rotationOffset;
const cos = Math.cos(totalRotation);
const sin = Math.sin(totalRotation);
// 转换为弧度 | Convert to radians
const totalRotationRad = (rotationDeg + collider.rotationOffset) * DEG_TO_RAD;
const cos = Math.cos(totalRotationRad);
const sin = Math.sin(totalRotationRad);
const worldX = transform.position.x + collider.offset.x * transform.scale.x;
const worldY = transform.position.y + collider.offset.y * transform.scale.y;
// Clockwise rotation for polygon vertices
// 多边形顶点的顺时针旋转
const worldPoints = collider.vertices.map(v => {
const scaledX = v.x * transform.scale.x;
const scaledY = v.y * transform.scale.y;
const rotatedX = scaledX * cos - scaledY * sin;
const rotatedY = scaledX * sin + scaledY * cos;
const rotatedX = scaledX * cos + scaledY * sin;
const rotatedY = -scaledX * sin + scaledY * cos;
return {
x: worldX + rotatedX,
y: worldY + rotatedY

View File

@@ -3,10 +3,35 @@
* 2D 物理世界封装
*
* 封装 Rapier2D 物理世界,提供确定性物理模拟
*
* 坐标转换说明:
* - ESEngine: 左手坐标系,顺时针正旋转,角度单位为度
* - Rapier2D: 数学坐标系,逆时针正旋转,角度单位为弧度
*/
import type RAPIER from '@esengine/rapier2d';
import type { IVector2 } from '@esengine/ecs-framework-math';
// 角度单位转换常量 | Angle unit conversion constants
const DEG_TO_RAD = Math.PI / 180;
const RAD_TO_DEG = 180 / Math.PI;
/**
* 将引擎旋转(度,顺时针)转换为 Rapier 旋转(弧度,逆时针)
* Convert engine rotation (degrees, clockwise) to Rapier rotation (radians, counter-clockwise)
*/
function toRapierRotation(degrees: number): number {
return -degrees * DEG_TO_RAD;
}
/**
* 将 Rapier 旋转(弧度,逆时针)转换为引擎旋转(度,顺时针)
* Convert Rapier rotation (radians, counter-clockwise) to engine rotation (degrees, clockwise)
*/
function fromRapierRotation(radians: number): number {
return -radians * RAD_TO_DEG;
}
import type {
Physics2DConfig,
RaycastHit2D,
@@ -223,9 +248,10 @@ export class Physics2DWorld {
}
// 设置刚体属性
// 转换旋转:引擎(度,顺时针)→ Rapier弧度逆时针
bodyDesc
.setTranslation(position.x, position.y)
.setRotation(rotation)
.setRotation(toRapierRotation(rotation))
.setLinearDamping(rigidbody.linearDamping)
.setAngularDamping(rigidbody.angularDamping)
.setGravityScale(rigidbody.gravityScale)
@@ -306,7 +332,8 @@ export class Physics2DWorld {
if (!body) return;
body.setTranslation(new this._rapier.Vector2(position.x, position.y), true);
body.setRotation(rotation, true);
// 转换旋转:引擎(度,顺时针)→ Rapier弧度逆时针
body.setRotation(toRapierRotation(rotation), true);
}
/**
@@ -333,7 +360,8 @@ export class Physics2DWorld {
const body = this._world.getRigidBody(handle);
if (!body) return null;
return body.rotation();
// 转换旋转Rapier弧度逆时针→ 引擎(度,顺时针)
return fromRapierRotation(body.rotation());
}
/**
@@ -803,7 +831,7 @@ export class Physics2DWorld {
const shape = new this._rapier.Cuboid(halfExtents.x, halfExtents.y);
const shapePos = new this._rapier.Vector2(center.x, center.y);
this._world.intersectionsWithShape(shapePos, rotation, shape, (collider) => {
this._world.intersectionsWithShape(shapePos, toRapierRotation(rotation), shape, (collider) => {
const mapping = this._colliderMap.get(collider.handle);
if (mapping && (collider.collisionGroups() & collisionMask) !== 0) {
entityIds.push(mapping.entityId);
@@ -1016,7 +1044,7 @@ export class Physics2DWorld {
// 配置碰撞体属性
colliderDesc
.setTranslation(collider.offset.x * sx, collider.offset.y * sy)
.setRotation(collider.rotationOffset)
.setRotation(toRapierRotation(collider.rotationOffset))
.setFriction(collider.friction)
.setRestitution(collider.restitution)
.setDensity(collider.density)

View File

@@ -23,7 +23,7 @@ import {
TransformTypeToken,
CanvasElementToken
} from '@esengine/engine-core';
import { AssetManager, EngineIntegration, AssetManagerToken } from '@esengine/asset-system';
import { AssetManager, EngineIntegration, AssetManagerToken, setGlobalAssetDatabase } from '@esengine/asset-system';
// ============================================================================
// 本地服务令牌定义 | Local Service Token Definitions
@@ -347,6 +347,10 @@ export class GameRuntime {
this._assetManager = new AssetManager();
this._engineIntegration = new EngineIntegration(this._assetManager, this._bridge);
// 设置全局资产数据库(供渲染系统查询 sprite 元数据)
// Set global asset database (for render systems to query sprite metadata)
setGlobalAssetDatabase(this._assetManager.getDatabase());
// 9. 加载并初始化插件(编辑器模式下跳过,由 editor-core 的 PluginManager 处理)
if (!this._config.skipPluginLoading) {
await this._initializePlugins();
@@ -1034,6 +1038,8 @@ export class GameRuntime {
if (this._assetManager) {
this._assetManager.dispose();
this._assetManager = null;
// 清除全局资产数据库引用 | Clear global asset database reference
setGlobalAssetDatabase(null);
}
this._engineIntegration = null;

View File

@@ -31,6 +31,7 @@
"@esengine/ecs-framework": "workspace:*",
"@esengine/asset-system": "workspace:*",
"@esengine/engine-core": "workspace:*",
"@esengine/material-system": "workspace:*",
"@esengine/build-config": "workspace:*",
"rimraf": "^5.0.5",
"tsup": "^8.0.0",

View File

@@ -0,0 +1,175 @@
/**
* Shiny effect component for sprite elements.
* 精灵元素的闪光效果组件。
*
* This component configures a sweeping highlight animation that moves across
* the sprite's texture.
* 此组件配置一个扫过精灵纹理的高光动画。
*/
import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework';
import type { IShinyEffect } from '@esengine/material-system';
import {
SHINY_EFFECT_DEFAULTS,
resetShinyEffect,
startShinyEffect,
stopShinyEffect,
getShinyRotationRadians
} from '@esengine/material-system';
/**
* Shiny effect component.
* 闪光效果组件。
*
* Adds a sweeping highlight animation to sprites.
* 为精灵添加扫光动画效果。
*
* @example
* ```typescript
* // Add shiny effect to an entity with SpriteComponent
* const shiny = entity.addComponent(ShinyEffectComponent);
* shiny.play = true;
* shiny.loop = true;
* shiny.duration = 2.0;
* shiny.loopDelay = 2.0;
* ```
*/
@ECSComponent('ShinyEffect', { requires: ['Sprite'] })
@Serializable({ version: 1, typeId: 'ShinyEffect' })
export class ShinyEffectComponent extends Component implements IShinyEffect {
// ============= Effect Parameters =============
// ============= 效果参数 =============
/**
* Width of the shiny band (0.0 - 1.0).
* 闪光带宽度 (0.0 - 1.0)。
*/
@Serialize()
@Property({ type: 'number', label: 'Width', min: 0, max: 1, step: 0.01 })
public width: number = 0.25;
/**
* Rotation angle in degrees.
* 旋转角度(度)。
*/
@Serialize()
@Property({ type: 'number', label: 'Rotation', min: 0, max: 360, step: 1 })
public rotation: number = 129;
/**
* Edge softness (0.0 - 1.0).
* 边缘柔和度 (0.0 - 1.0)。
*/
@Serialize()
@Property({ type: 'number', label: 'Softness', min: 0, max: 1, step: 0.01 })
public softness: number = 1.0;
/**
* Brightness multiplier.
* 亮度倍增器。
*/
@Serialize()
@Property({ type: 'number', label: 'Brightness', min: 0, max: 2, step: 0.01 })
public brightness: number = 1.0;
/**
* Gloss intensity.
* 光泽度。
*/
@Serialize()
@Property({ type: 'number', label: 'Gloss', min: 0, max: 2, step: 0.01 })
public gloss: number = 1.0;
// ============= Animation Settings =============
// ============= 动画设置 =============
/**
* Whether the animation is playing.
* 动画是否正在播放。
*/
@Serialize()
@Property({ type: 'boolean', label: 'Play' })
public play: boolean = true;
/**
* Whether to loop the animation.
* 是否循环动画。
*/
@Serialize()
@Property({ type: 'boolean', label: 'Loop' })
public loop: boolean = true;
/**
* Animation duration in seconds.
* 动画持续时间(秒)。
*/
@Serialize()
@Property({ type: 'number', label: 'Duration', min: 0.1, step: 0.1 })
public duration: number = 2.0;
/**
* Delay between loops in seconds.
* 循环之间的延迟(秒)。
*/
@Serialize()
@Property({ type: 'number', label: 'Loop Delay', min: 0, step: 0.1 })
public loopDelay: number = 2.0;
/**
* Initial delay before first play in seconds.
* 首次播放前的初始延迟(秒)。
*/
@Serialize()
@Property({ type: 'number', label: 'Initial Delay', min: 0, step: 0.1 })
public initialDelay: number = 0;
// ============= Runtime State (not serialized) =============
// ============= 运行时状态(不序列化)=============
/** Current animation progress (0.0 - 1.0). | 当前动画进度。 */
public progress: number = 0;
/** Current elapsed time in the animation cycle. | 当前周期已用时间。 */
public elapsedTime: number = 0;
/** Whether currently in delay phase. | 是否处于延迟阶段。 */
public inDelay: boolean = false;
/** Remaining delay time. | 剩余延迟时间。 */
public delayRemaining: number = 0;
/** Whether the initial delay has been processed. | 初始延迟是否已处理。 */
public initialDelayProcessed: boolean = false;
/**
* Reset the animation to the beginning.
* 重置动画到开始状态。
*/
reset(): void {
resetShinyEffect(this);
}
/**
* Start playing the animation.
* 开始播放动画。
*/
start(): void {
startShinyEffect(this);
}
/**
* Stop the animation.
* 停止动画。
*/
stop(): void {
stopShinyEffect(this);
}
/**
* Get rotation in radians for shader use.
* 获取弧度制的旋转角度供着色器使用。
*/
getRotationRadians(): number {
return getShinyRotationRadians(this);
}
}

View File

@@ -1,27 +1,11 @@
import type { AssetReference } from '@esengine/asset-system';
import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework';
import { SortingLayers, type ISortable } from '@esengine/engine-core';
/**
* Material property override value.
* 材质属性覆盖值。
*
* Used to override specific uniform parameters on a per-instance basis
* without creating a new material instance.
* 用于在每个实例上覆盖特定的 uniform 参数,而无需创建新的材质实例。
*/
export interface MaterialPropertyOverride {
/** Uniform type. | Uniform 类型。 */
type: 'float' | 'vec2' | 'vec3' | 'vec4' | 'color' | 'int';
/** Uniform value. | Uniform 值。 */
value: number | number[];
}
/**
* Material property overrides map.
* 材质属性覆盖映射。
*/
export type MaterialOverrides = Record<string, MaterialPropertyOverride>;
import type {
IMaterialOverridable,
MaterialPropertyOverride,
MaterialOverrides
} from '@esengine/material-system';
/**
* 精灵组件 - 管理2D图像渲染
@@ -32,7 +16,7 @@ export type MaterialOverrides = Record<string, MaterialPropertyOverride>;
*/
@ECSComponent('Sprite', { requires: ['Transform'] })
@Serializable({ version: 5, typeId: 'Sprite' })
export class SpriteComponent extends Component implements ISortable {
export class SpriteComponent extends Component implements ISortable, IMaterialOverridable {
/**
* 纹理资产 GUID
* Texture asset GUID

View File

@@ -1,8 +1,12 @@
export { SpriteComponent } from './SpriteComponent';
export type { MaterialPropertyOverride, MaterialOverrides } from './SpriteComponent';
// Re-export material types from material-system for convenience
// 从 material-system 重新导出材质类型以方便使用
export type { MaterialPropertyOverride, MaterialOverrides } from '@esengine/material-system';
export { SpriteAnimatorComponent } from './SpriteAnimatorComponent';
export type { AnimationFrame, AnimationClip } from './SpriteAnimatorComponent';
export { ShinyEffectComponent } from './ShinyEffectComponent';
export { SpriteAnimatorSystem } from './systems/SpriteAnimatorSystem';
export { ShinyEffectSystem } from './systems/ShinyEffectSystem';
export { SpriteRuntimeModule, SpritePlugin } from './SpriteRuntimeModule';
// Service tokens | 服务令牌

View File

@@ -0,0 +1,46 @@
/**
* Shiny effect animation system.
* 闪光效果动画系统。
*
* Updates ShinyEffectComponent animations and applies material overrides
* to the associated SpriteComponent.
* 更新 ShinyEffectComponent 动画并将材质覆盖应用到关联的 SpriteComponent。
*/
import { EntitySystem, Matcher, ECSSystem, Time, Entity } from '@esengine/ecs-framework';
import { ShinyEffectAnimator } from '@esengine/material-system';
import { ShinyEffectComponent } from '../ShinyEffectComponent';
import { SpriteComponent } from '../SpriteComponent';
/**
* System that animates shiny effects on sprites.
* 为精灵动画闪光效果的系统。
*/
@ECSSystem('ShinyEffect', { updateOrder: 100 })
export class ShinyEffectSystem extends EntitySystem {
constructor() {
super(Matcher.empty().all(ShinyEffectComponent));
}
/**
* Process all entities with ShinyEffectComponent.
* 处理所有带有 ShinyEffectComponent 的实体。
*/
protected override process(entities: readonly Entity[]): void {
const deltaTime = Time.deltaTime;
for (const entity of entities) {
if (!entity.enabled) continue;
const shiny = entity.getComponent(ShinyEffectComponent);
if (!shiny || !shiny.play) continue;
const sprite = entity.getComponent(SpriteComponent);
if (!sprite) continue;
// Use shared animator logic
// 使用共享的动画器逻辑
ShinyEffectAnimator.processEffect(shiny, sprite, deltaTime);
}
}
}

View File

@@ -4,6 +4,9 @@ import { Color } from '@esengine/ecs-framework-math';
import type { IRenderDataProvider } from '@esengine/ecs-engine-bindgen';
import { TilemapComponent, type ITilemapLayerData } from '../TilemapComponent';
/** 度转弧度常量 | Degrees to radians constant */
const DEG_TO_RAD = Math.PI / 180;
/**
* Tilemap render data for a single tilemap layer
* 单个瓦片地图图层的渲染数据
@@ -186,9 +189,10 @@ export class TilemapRenderingSystem extends EntitySystem implements IRenderDataP
const colorValue = Color.packHexAlpha(tilemap.color, effectiveAlpha);
// Calculate rotation parameters
// 计算旋转参数
const cos = Math.cos(transform.rotation.z);
const sin = Math.sin(transform.rotation.z);
// 计算旋转参数(度转弧度)
const rotationRad = transform.rotation.z * DEG_TO_RAD;
const cos = Math.cos(rotationRad);
const sin = Math.sin(rotationRad);
// Tilemap rotation pivot
// Tilemap 旋转中心点
@@ -221,10 +225,10 @@ export class TilemapRenderingSystem extends EntitySystem implements IRenderDataP
const localX = transform.position.x + col * tileWidth * transform.scale.x + (tileWidth * transform.scale.x) / 2 - pivotX;
const localY = transform.position.y + (height - 1 - row) * tileHeight * transform.scale.y + (tileHeight * transform.scale.y) / 2 - pivotY;
// Apply rotation transform
// 应用旋转变换
const rotatedX = localX * cos - localY * sin + pivotX;
const rotatedY = localX * sin + localY * cos + pivotY;
// Apply rotation transform (clockwise positive)
// 应用旋转变换(顺时针为正)
const rotatedX = localX * cos + localY * sin + pivotX;
const rotatedY = -localX * sin + localY * cos + pivotY;
// Transform: [x, y, rotation, scaleX, scaleY, originX, originY]
const tOffset = idx * 7;
@@ -301,9 +305,10 @@ export class TilemapRenderingSystem extends EntitySystem implements IRenderDataP
);
// Calculate rotation parameters
// 计算旋转参数
const cos = Math.cos(transform.rotation.z);
const sin = Math.sin(transform.rotation.z);
// 计算旋转参数(度转弧度)
const rotationRad = transform.rotation.z * DEG_TO_RAD;
const cos = Math.cos(rotationRad);
const sin = Math.sin(rotationRad);
// Tilemap rotation pivot
// Tilemap 旋转中心点
@@ -320,10 +325,10 @@ export class TilemapRenderingSystem extends EntitySystem implements IRenderDataP
const localX = transform.position.x + col * tileWidth * transform.scale.x + (tileWidth * transform.scale.x) / 2 - pivotX;
const localY = transform.position.y + (height - 1 - row) * tileHeight * transform.scale.y + (tileHeight * transform.scale.y) / 2 - pivotY;
// Apply rotation transform
// 应用旋转变换
const rotatedX = localX * cos - localY * sin + pivotX;
const rotatedY = localX * sin + localY * cos + pivotY;
// Apply rotation transform (clockwise positive)
// 应用旋转变换(顺时针为正)
const rotatedX = localX * cos + localY * sin + pivotX;
const rotatedY = -localX * sin + localY * cos + pivotY;
const tOffset = idx * 7;
renderData.transforms[tOffset] = rotatedX;

View File

@@ -30,6 +30,7 @@
"@esengine/ecs-framework": "workspace:*",
"@esengine/editor-core": "workspace:*",
"@esengine/editor-runtime": "workspace:*",
"@esengine/material-system": "workspace:*",
"@esengine/build-config": "workspace:*",
"lucide-react": "^0.545.0",
"react": "^18.3.1",

View File

@@ -15,21 +15,23 @@ function uiTransformGizmoProvider(
return [];
}
// Use world coordinates (computed by UILayoutSystem) if available
// Otherwise fallback to local coordinates
// 使用世界坐标(由 UILayoutSystem 计算),如果可用
// 否则回退到本地坐标
const x = transform.worldX ?? transform.x;
const y = transform.worldY ?? transform.y;
// Use world scale for proper hierarchical transform inheritance
// 使用世界缩放以正确继承层级变换
// 使用 UILayoutSystem 计算的世界坐标
// Use world coordinates computed by UILayoutSystem
// 如果 layoutComputed = false说明 UILayoutSystem 还没运行,回退到本地坐标
// If layoutComputed = false, UILayoutSystem hasn't run yet, fallback to local coordinates
const x = transform.layoutComputed ? transform.worldX : transform.x;
const y = transform.layoutComputed ? transform.worldY : transform.y;
const scaleX = transform.worldScaleX ?? transform.scaleX;
const scaleY = transform.worldScaleY ?? transform.scaleY;
const width = (transform.computedWidth ?? transform.width) * scaleX;
const height = (transform.computedHeight ?? transform.height) * scaleY;
// Use world rotation for proper hierarchical transform inheritance
// 使用世界旋转以正确继承层级变换
const rotation = transform.worldRotation ?? transform.rotation;
const width = (transform.layoutComputed && transform.computedWidth > 0
? transform.computedWidth
: transform.width) * scaleX;
const height = (transform.layoutComputed && transform.computedHeight > 0
? transform.computedHeight
: transform.height) * scaleY;
// 角度转弧度 | Convert degrees to radians
const rotationDegrees = transform.worldRotation ?? transform.rotation;
const rotation = (rotationDegrees * Math.PI) / 180;
// 使用 transform 的 pivot 作为旋转/缩放中心
const pivotX = transform.pivotX;
const pivotY = transform.pivotY;

View File

@@ -33,11 +33,11 @@ import {
UISliderComponent,
UIScrollViewComponent
} from '@esengine/ui';
import { UITransformInspector } from './inspectors';
import { UITransformInspector, UIRenderInspector } from './inspectors';
import { registerUITransformGizmo, unregisterUITransformGizmo } from './gizmos';
// Re-exports
export { UITransformInspector } from './inspectors';
export { UITransformInspector, UIRenderInspector } from './inspectors';
export { registerUITransformGizmo, unregisterUITransformGizmo } from './gizmos';
/**
@@ -76,6 +76,7 @@ export class UIEditorModule implements IEditorModuleLoader {
const componentInspectorRegistry = services.tryResolve(ComponentInspectorRegistry);
if (componentInspectorRegistry) {
componentInspectorRegistry.register(new UITransformInspector());
componentInspectorRegistry.register(new UIRenderInspector());
}
// 注册 Gizmo | Register gizmo

View File

@@ -0,0 +1,852 @@
/**
* UI Render Component Inspector.
* UI 渲染组件检查器。
*
* Provides unified material editing for UIRenderComponent.
* 为 UIRenderComponent 提供统一的材质编辑。
*/
import React, { useState, useCallback, useMemo } from 'react';
import { Component, Core } from '@esengine/ecs-framework';
import type { IComponentInspector, ComponentInspectorContext } from '@esengine/editor-core';
import { MessageHub } from '@esengine/editor-core';
import { UIRenderComponent } from '@esengine/ui';
import type { ShaderPropertyMeta } from '@esengine/material-system';
import { getShaderPropertiesById } from '@esengine/material-system';
import { ChevronDown, ChevronRight, Palette, X, Plus, FileBox } from 'lucide-react';
/**
* Material source type.
* 材质来源类型。
*/
type MaterialSource = 'none' | 'builtin' | 'asset';
/**
* Built-in effect options.
* 内置效果选项。
*/
const BUILTIN_EFFECTS = [
{ id: 1, name: 'Grayscale', description: 'Convert to grayscale' },
{ id: 2, name: 'Tint', description: 'Apply color tint' },
{ id: 3, name: 'Flash', description: 'Flash effect for hit feedback' },
{ id: 4, name: 'Outline', description: 'Add outline border' },
{ id: 5, name: 'Shiny', description: 'Animated shine sweep' },
];
// Uniform type display names
const UNIFORM_TYPE_LABELS: Record<string, string> = {
'float': 'Float',
'int': 'Int',
'vec2': 'Vec2',
'vec3': 'Vec3',
'vec4': 'Vec4',
'color': 'Color',
};
/**
* Single number input with local state to prevent focus loss.
* 带本地状态的单数字输入框,防止失焦。
*/
function NumberInput({ value, onChange, min, max, step, style }: {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
style?: React.CSSProperties;
}) {
const [localValue, setLocalValue] = useState(String(value));
const [isFocused, setIsFocused] = useState(false);
// Sync from prop when not focused
// 未聚焦时从 prop 同步
React.useEffect(() => {
if (!isFocused) {
setLocalValue(String(value));
}
}, [value, isFocused]);
const handleBlur = () => {
setIsFocused(false);
const parsed = parseFloat(localValue);
if (!isNaN(parsed)) {
onChange(parsed);
} else {
setLocalValue(String(value));
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
(e.target as HTMLInputElement).blur();
}
};
return (
<input
type="number"
value={localValue}
min={min}
max={max}
step={step}
onChange={(e) => setLocalValue(e.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
style={style}
/>
);
}
/**
* Convert radians to degrees.
* 弧度转角度。
*/
function radToDeg(rad: number): number {
return rad * 180 / Math.PI;
}
/**
* Convert degrees to radians.
* 角度转弧度。
*/
function degToRad(deg: number): number {
return deg * Math.PI / 180;
}
/**
* Property value editor component.
* 属性值编辑器组件。
*/
function PropertyValueEditor({ meta, value, onChange }: {
meta: ShaderPropertyMeta;
value: number | number[];
onChange: (value: number | number[]) => void;
}) {
const inputStyle: React.CSSProperties = {
backgroundColor: 'var(--color-bg-inset)',
color: 'var(--color-text-primary)',
border: '1px solid var(--color-border-default)',
borderRadius: 'var(--radius-sm)',
padding: '2px 6px',
fontSize: '11px',
width: '60px'
};
switch (meta.type) {
case 'float':
case 'int': {
// Handle 'angle' hint: display degrees, store radians
// 处理 'angle' 提示:显示角度,存储弧度
const isAngle = meta.hint === 'angle';
const numValue = typeof value === 'number' ? value : 0;
const displayValue = isAngle ? radToDeg(numValue) : numValue;
const displayMin = isAngle && meta.min !== undefined ? radToDeg(meta.min) : meta.min;
const displayMax = isAngle && meta.max !== undefined ? radToDeg(meta.max) : meta.max;
const displayStep = isAngle ? 1 : (meta.step ?? (meta.type === 'int' ? 1 : 0.01));
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<NumberInput
value={displayValue}
min={displayMin}
max={displayMax}
step={displayStep}
onChange={(v) => {
const storeValue = isAngle ? degToRad(v) : v;
(onChange as (v: number) => void)(storeValue);
}}
style={inputStyle}
/>
{isAngle && (
<span style={{ color: 'var(--color-text-tertiary)', fontSize: '10px' }}>°</span>
)}
</div>
);
}
case 'vec2': {
const v2 = Array.isArray(value) ? value : [0, 0];
return (
<div style={{ display: 'flex', gap: '4px' }}>
<NumberInput
value={v2[0] ?? 0}
step={meta.step ?? 0.01}
onChange={(v) => onChange([v, v2[1] ?? 0])}
style={{ ...inputStyle, width: '50px' }}
/>
<NumberInput
value={v2[1] ?? 0}
step={meta.step ?? 0.01}
onChange={(v) => onChange([v2[0] ?? 0, v])}
style={{ ...inputStyle, width: '50px' }}
/>
</div>
);
}
case 'vec3': {
const v3 = Array.isArray(value) ? value : [0, 0, 0];
return (
<div style={{ display: 'flex', gap: '4px' }}>
{[0, 1, 2].map(i => (
<NumberInput
key={i}
value={v3[i] ?? 0}
step={meta.step ?? 0.01}
onChange={(v) => {
const newVal = [...v3];
newVal[i] = v;
onChange(newVal);
}}
style={{ ...inputStyle, width: '40px' }}
/>
))}
</div>
);
}
case 'vec4': {
const v4 = Array.isArray(value) ? value : [0, 0, 0, 0];
return (
<div style={{ display: 'flex', gap: '4px' }}>
{[0, 1, 2, 3].map(i => (
<NumberInput
key={i}
value={v4[i] ?? 0}
step={meta.step ?? 0.01}
onChange={(v) => {
const newVal = [...v4];
newVal[i] = v;
onChange(newVal);
}}
style={{ ...inputStyle, width: '35px' }}
/>
))}
</div>
);
}
case 'color': {
const c = Array.isArray(value) ? value : [1, 1, 1, 1];
const cr = c[0] ?? 1;
const cg = c[1] ?? 1;
const cb = c[2] ?? 1;
const ca = c[3] ?? 1;
const hexColor = `#${Math.round(cr * 255).toString(16).padStart(2, '0')}${Math.round(cg * 255).toString(16).padStart(2, '0')}${Math.round(cb * 255).toString(16).padStart(2, '0')}`;
return (
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
<input
type="color"
value={hexColor}
onChange={(e) => {
const hex = e.target.value;
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
onChange([r, g, b, ca]);
}}
style={{ width: '24px', height: '20px', padding: 0, border: 'none' }}
/>
<NumberInput
value={ca}
min={0}
max={1}
step={0.01}
onChange={(v) => onChange([cr, cg, cb, v])}
style={{ ...inputStyle, width: '40px' }}
/>
</div>
);
}
default:
return <span style={{ color: 'var(--color-text-tertiary)' }}>Unsupported</span>;
}
}
/**
* Determine material source from component state.
* 从组件状态确定材质来源。
*/
function getMaterialSource(render: UIRenderComponent): MaterialSource {
if (render.materialGuid && render.materialGuid.length > 0) {
return 'asset';
}
if (render.getMaterialId() !== 0) {
return 'builtin';
}
return 'none';
}
/**
* UI Render Inspector content component.
* UI 渲染检查器内容组件。
*/
function UIRenderInspectorContent({ context }: { context: ComponentInspectorContext }) {
const render = context.component as UIRenderComponent;
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(['Effect', 'Default']));
const [showAddMenu, setShowAddMenu] = useState(false);
const [, forceUpdate] = useState({});
// Determine current state
const materialSource = getMaterialSource(render);
const materialId = render.getMaterialId();
const properties = getShaderPropertiesById(materialId);
// Get effect name for display
const effectName = BUILTIN_EFFECTS.find(e => e.id === materialId)?.name || '';
// Group properties
const groupedProps = useMemo(() => {
if (!properties) return {};
const groups: Record<string, Array<[string, ShaderPropertyMeta]>> = {};
for (const [name, meta] of Object.entries(properties) as [string, ShaderPropertyMeta][]) {
if (meta.hidden) continue;
const group = meta.group || 'Default';
if (!groups[group]) groups[group] = [];
groups[group].push([name, meta]);
}
return groups;
}, [properties]);
// Get available properties for override
const availableProperties = useMemo((): Array<{ name: string; meta: ShaderPropertyMeta }> => {
if (!properties) return [];
const currentOverrides = render.materialOverrides || {};
return (Object.entries(properties) as [string, ShaderPropertyMeta][])
.filter(([name, meta]) => !meta.hidden && !currentOverrides[name])
.map(([name, meta]) => ({ name, meta }));
}, [properties, render.materialOverrides]);
const toggleGroup = (group: string) => {
setExpandedGroups(prev => {
const next = new Set(prev);
if (next.has(group)) {
next.delete(group);
} else {
next.add(group);
}
return next;
});
};
const notifyChange = useCallback(() => {
forceUpdate({});
const messageHub = Core.services.tryResolve(MessageHub);
if (messageHub) {
messageHub.publish('scene:modified', {});
}
}, []);
// Handle source change
const handleSourceChange = useCallback((newSource: MaterialSource) => {
if (newSource === 'none') {
render.materialGuid = '';
render.setMaterialId(0);
render.clearOverrides();
} else if (newSource === 'builtin') {
render.materialGuid = '';
// Set to first effect if currently none
if (render.getMaterialId() === 0) {
render.setMaterialId(1); // Grayscale
}
render.clearOverrides();
} else if (newSource === 'asset') {
render.setMaterialId(0);
render.clearOverrides();
// materialGuid will be set by asset picker
}
context.onChange?.('materialGuid', render.materialGuid);
notifyChange();
}, [render, context, notifyChange]);
// Handle effect change
const handleEffectChange = useCallback((effectId: number) => {
render.setMaterialId(effectId);
render.clearOverrides();
context.onChange?.('_materialId', effectId);
notifyChange();
}, [render, context, notifyChange]);
// Handle asset change
const handleAssetChange = useCallback((assetGuid: string) => {
render.materialGuid = assetGuid;
context.onChange?.('materialGuid', assetGuid);
notifyChange();
}, [render, context, notifyChange]);
// Handle property change
const handlePropertyChange = useCallback((name: string, meta: ShaderPropertyMeta, newValue: number | number[]) => {
switch (meta.type) {
case 'float':
render.setOverrideFloat(name, newValue as number);
break;
case 'int':
render.setOverrideInt(name, newValue as number);
break;
case 'vec2': {
const v2 = newValue as number[];
render.setOverrideVec2(name, v2[0] ?? 0, v2[1] ?? 0);
break;
}
case 'vec3': {
const v3 = newValue as number[];
render.setOverrideVec3(name, v3[0] ?? 0, v3[1] ?? 0, v3[2] ?? 0);
break;
}
case 'vec4': {
const v4 = newValue as number[];
render.setOverrideVec4(name, v4[0] ?? 0, v4[1] ?? 0, v4[2] ?? 0, v4[3] ?? 0);
break;
}
case 'color': {
const c = newValue as number[];
render.setOverrideColor(name, c[0] ?? 1, c[1] ?? 1, c[2] ?? 1, c[3] ?? 1);
break;
}
}
context.onChange?.('materialOverrides', render.materialOverrides);
notifyChange();
}, [render, context, notifyChange]);
const handleRemoveOverride = useCallback((name: string) => {
render.removeOverride(name);
context.onChange?.('materialOverrides', render.materialOverrides);
notifyChange();
}, [render, context, notifyChange]);
const handleAddOverride = useCallback((name: string, meta: ShaderPropertyMeta) => {
const defaultValue = meta.default ?? (meta.type === 'color' ? [1, 1, 1, 1] : 0);
handlePropertyChange(name, meta, defaultValue as number | number[]);
setShowAddMenu(false);
}, [handlePropertyChange]);
const getCurrentValue = (name: string, meta: ShaderPropertyMeta): number | number[] => {
const override = render.getOverride(name);
if (override) {
return override.value as number | number[];
}
return meta.default as number | number[] ?? (meta.type === 'color' ? [1, 1, 1, 1] : 0);
};
const currentOverrides = render.materialOverrides || {};
const overrideKeys = Object.keys(currentOverrides);
// Styles
const selectStyle: React.CSSProperties = {
flex: 1,
backgroundColor: 'var(--color-bg-inset)',
color: 'var(--color-text-primary)',
border: '1px solid var(--color-border-default)',
borderRadius: 'var(--radius-sm)',
padding: '4px 8px',
fontSize: '12px'
};
const rowStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
padding: '4px 8px',
marginBottom: '4px'
};
const labelStyle: React.CSSProperties = {
color: 'var(--color-text-secondary)',
marginRight: '8px',
minWidth: '60px',
fontSize: '12px'
};
return (
<div style={{ fontSize: '12px', marginTop: '8px' }}>
{/* Section header */}
<div style={{
padding: '6px 8px',
backgroundColor: 'var(--color-bg-elevated)',
borderRadius: 'var(--radius-sm)',
marginBottom: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Palette size={14} style={{ color: materialSource !== 'none' ? 'var(--color-primary)' : 'var(--color-text-tertiary)' }} />
<span style={{ fontWeight: 500, color: 'var(--color-text-primary)' }}>Material</span>
</div>
{materialSource === 'builtin' && effectName && (
<span style={{
fontSize: '10px',
padding: '2px 6px',
backgroundColor: 'var(--color-primary-subtle)',
color: 'var(--color-primary)',
borderRadius: 'var(--radius-sm)'
}}>
{effectName}
</span>
)}
{materialSource === 'asset' && (
<span style={{
fontSize: '10px',
padding: '2px 6px',
backgroundColor: 'var(--color-success-subtle)',
color: 'var(--color-success)',
borderRadius: 'var(--radius-sm)'
}}>
Asset
</span>
)}
</div>
{/* Source selector */}
<div style={rowStyle}>
<span style={labelStyle}>Source</span>
<select
value={materialSource}
onChange={(e) => handleSourceChange(e.target.value as MaterialSource)}
style={selectStyle}
>
<option value="none">None (Default)</option>
<option value="builtin">Built-in Effect</option>
<option value="asset">Material Asset</option>
</select>
</div>
{/* None selected hint */}
{materialSource === 'none' && (
<div style={{
padding: '12px',
margin: '4px 8px 8px',
backgroundColor: 'var(--color-bg-subtle)',
borderRadius: 'var(--radius-sm)',
color: 'var(--color-text-tertiary)',
fontSize: '11px',
textAlign: 'center'
}}>
No material effect applied.<br />
Select a source above to add visual effects.
</div>
)}
{/* Built-in effect selector */}
{materialSource === 'builtin' && (
<>
<div style={rowStyle}>
<span style={labelStyle}>Effect</span>
<select
value={materialId}
onChange={(e) => handleEffectChange(Number(e.target.value))}
style={selectStyle}
>
{BUILTIN_EFFECTS.map(effect => (
<option key={effect.id} value={effect.id}>
{effect.name}
</option>
))}
</select>
</div>
{/* Effect description */}
{effectName && (
<div style={{
padding: '4px 8px',
marginBottom: '8px',
color: 'var(--color-text-tertiary)',
fontSize: '10px',
fontStyle: 'italic'
}}>
{BUILTIN_EFFECTS.find(e => e.id === materialId)?.description}
</div>
)}
{/* Overrides section */}
{overrideKeys.length > 0 && (
<div style={{ marginBottom: '8px' }}>
<div style={{
padding: '4px 8px',
backgroundColor: 'var(--color-bg-subtle)',
borderRadius: 'var(--radius-sm)',
marginBottom: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<span style={{ color: 'var(--color-text-secondary)', fontSize: '11px' }}>
Overrides ({overrideKeys.length})
</span>
</div>
{overrideKeys.map(key => {
const override = currentOverrides[key];
if (!override) return null;
const meta = properties?.[key];
if (!meta) return null;
return (
<div key={key} style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 8px',
borderBottom: '1px solid var(--color-border-muted)'
}}>
<span style={{ color: 'var(--color-text-secondary)', minWidth: '80px' }} title={meta.tooltip}>
{key.replace(/^u_/, '')}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<PropertyValueEditor
meta={meta}
value={override.value as number | number[]}
onChange={(v) => handlePropertyChange(key, meta, v)}
/>
<button
onClick={() => handleRemoveOverride(key)}
style={{
background: 'none',
border: 'none',
padding: '2px',
cursor: 'pointer',
color: 'var(--color-text-tertiary)',
display: 'flex',
alignItems: 'center'
}}
title="Remove override"
>
<X size={12} />
</button>
</div>
</div>
);
})}
</div>
)}
{/* Property groups */}
{Object.entries(groupedProps).map(([group, props]) => (
<div key={group} style={{ marginBottom: '4px' }}>
<div
onClick={() => toggleGroup(group)}
style={{
display: 'flex',
alignItems: 'center',
padding: '4px 8px',
backgroundColor: 'var(--color-bg-subtle)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
userSelect: 'none'
}}
>
{expandedGroups.has(group) ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<span style={{ marginLeft: '4px', color: 'var(--color-text-secondary)', fontWeight: 500 }}>{group}</span>
</div>
{expandedGroups.has(group) && (
<div style={{ padding: '4px 8px' }}>
{props.map(([name, meta]) => {
const hasOverride = !!currentOverrides[name];
return (
<div key={name} style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '3px 0',
borderBottom: '1px solid var(--color-border-muted)',
opacity: hasOverride ? 1 : 0.7
}}>
<span
style={{
color: hasOverride ? 'var(--color-text-primary)' : 'var(--color-text-tertiary)',
cursor: 'pointer'
}}
title={meta.tooltip || `Click to add override for ${name}`}
onClick={() => !hasOverride && handleAddOverride(name, meta)}
>
{name.replace(/^u_/, '')}
{!hasOverride && <Plus size={10} style={{ marginLeft: '4px', opacity: 0.5 }} />}
</span>
{hasOverride ? (
<PropertyValueEditor
meta={meta}
value={getCurrentValue(name, meta)}
onChange={(v) => handlePropertyChange(name, meta, v)}
/>
) : (
<span style={{ color: 'var(--color-text-tertiary)', fontSize: '10px' }}>
{typeof meta.default === 'number' ? meta.default.toFixed(2) : 'default'}
</span>
)}
</div>
);
})}
</div>
)}
</div>
))}
{/* Add override button */}
{availableProperties.length > 0 && (
<div style={{ position: 'relative', padding: '4px 8px' }}>
<button
onClick={() => setShowAddMenu(!showAddMenu)}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
backgroundColor: 'var(--color-bg-subtle)',
border: '1px solid var(--color-border-default)',
borderRadius: 'var(--radius-sm)',
color: 'var(--color-text-secondary)',
cursor: 'pointer',
fontSize: '11px'
}}
>
<Plus size={12} />
<span>Add Override</span>
</button>
{showAddMenu && (
<div style={{
position: 'absolute',
top: '100%',
left: '8px',
zIndex: 100,
backgroundColor: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border-default)',
borderRadius: 'var(--radius-sm)',
boxShadow: 'var(--shadow-lg)',
minWidth: '180px',
maxHeight: '200px',
overflowY: 'auto'
}}>
{availableProperties.map(({ name, meta }) => (
<button
key={name}
onClick={() => handleAddOverride(name, meta)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: '6px 8px',
backgroundColor: 'transparent',
border: 'none',
borderBottom: '1px solid var(--color-border-muted)',
color: 'var(--color-text-primary)',
cursor: 'pointer',
fontSize: '11px',
textAlign: 'left'
}}
>
<span>{name.replace(/^u_/, '')}</span>
<span style={{ color: 'var(--color-text-tertiary)', fontSize: '10px' }}>
{UNIFORM_TYPE_LABELS[meta.type] || meta.type}
</span>
</button>
))}
</div>
)}
</div>
)}
{/* Empty state for effect without properties */}
{!properties && (
<div style={{
padding: '8px 12px',
margin: '0 8px',
backgroundColor: 'var(--color-bg-subtle)',
borderRadius: 'var(--radius-sm)',
color: 'var(--color-text-tertiary)',
fontSize: '11px',
fontStyle: 'italic'
}}>
No editable properties for this effect
</div>
)}
</>
)}
{/* Material Asset selector */}
{materialSource === 'asset' && (
<div style={{ padding: '4px 8px' }}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px',
backgroundColor: 'var(--color-bg-subtle)',
borderRadius: 'var(--radius-sm)',
border: '1px dashed var(--color-border-default)'
}}>
<FileBox size={16} style={{ color: 'var(--color-text-tertiary)' }} />
<div style={{ flex: 1 }}>
<input
type="text"
value={render.materialGuid}
onChange={(e) => handleAssetChange(e.target.value)}
placeholder="Drag .mat file here or enter GUID"
style={{
width: '100%',
backgroundColor: 'var(--color-bg-inset)',
color: 'var(--color-text-primary)',
border: '1px solid var(--color-border-default)',
borderRadius: 'var(--radius-sm)',
padding: '4px 8px',
fontSize: '11px'
}}
/>
</div>
{render.materialGuid && (
<button
onClick={() => handleAssetChange('')}
style={{
background: 'none',
border: 'none',
padding: '2px',
cursor: 'pointer',
color: 'var(--color-text-tertiary)'
}}
title="Clear"
>
<X size={14} />
</button>
)}
</div>
<div style={{
padding: '8px',
color: 'var(--color-text-tertiary)',
fontSize: '10px'
}}>
Material assets (.mat) define shared shader configurations
</div>
</div>
)}
</div>
);
}
/**
* UI Render component inspector implementation.
* UI 渲染组件检查器实现。
*
* Uses 'append' mode to add unified material UI after default properties.
* 使用 'append' 模式在默认属性后添加统一的材质 UI。
*/
export class UIRenderInspector implements IComponentInspector<UIRenderComponent> {
readonly id = 'uirender-inspector';
readonly name = 'UIRender Inspector';
readonly priority = 100;
readonly targetComponents = ['UIRender', 'UIRenderComponent'];
readonly renderMode = 'append' as const;
canHandle(component: Component): component is UIRenderComponent {
return component instanceof UIRenderComponent ||
component.constructor.name === 'UIRenderComponent';
}
render(context: ComponentInspectorContext): React.ReactElement {
return React.createElement(UIRenderInspectorContent, {
context,
key: `uirender-${context.version}`
});
}
}

View File

@@ -202,6 +202,15 @@ const AnchorPresetGrid: React.FC<{
[AnchorPreset.BottomLeft]: { x: 3, y: 17 },
[AnchorPreset.BottomCenter]: { x: 10, y: 17 },
[AnchorPreset.BottomRight]: { x: 17, y: 17 },
// Stretch presets (horizontal) | 拉伸预设(水平)
[AnchorPreset.StretchTop]: { x: 10, y: 3 },
[AnchorPreset.StretchMiddle]: { x: 10, y: 10 },
[AnchorPreset.StretchBottom]: { x: 10, y: 17 },
// Stretch presets (vertical) | 拉伸预设(垂直)
[AnchorPreset.StretchLeft]: { x: 3, y: 10 },
[AnchorPreset.StretchCenter]: { x: 10, y: 10 },
[AnchorPreset.StretchRight]: { x: 17, y: 10 },
// Full stretch | 完全拉伸
[AnchorPreset.StretchAll]: { x: 10, y: 10 },
};
return positions[preset];
@@ -320,30 +329,44 @@ export class UITransformInspector implements IComponentInspector<UITransformComp
return AnchorPreset.StretchAll;
}
if (anchorMinX === anchorMaxX && anchorMinY === anchorMaxY) {
if (anchorMinX === 0 && anchorMinY === 0) return AnchorPreset.TopLeft;
if (anchorMinX === 0.5 && anchorMinY === 0) return AnchorPreset.TopCenter;
if (anchorMinX === 1 && anchorMinY === 0) return AnchorPreset.TopRight;
// Y-up 坐标系anchorMinY=1 是顶部anchorMinY=0 是底部
// Y-up coordinate system: anchorMinY=1 is top, anchorMinY=0 is bottom
if (anchorMinX === 0 && anchorMinY === 1) return AnchorPreset.TopLeft;
if (anchorMinX === 0.5 && anchorMinY === 1) return AnchorPreset.TopCenter;
if (anchorMinX === 1 && anchorMinY === 1) return AnchorPreset.TopRight;
if (anchorMinX === 0 && anchorMinY === 0.5) return AnchorPreset.MiddleLeft;
if (anchorMinX === 0.5 && anchorMinY === 0.5) return AnchorPreset.MiddleCenter;
if (anchorMinX === 1 && anchorMinY === 0.5) return AnchorPreset.MiddleRight;
if (anchorMinX === 0 && anchorMinY === 1) return AnchorPreset.BottomLeft;
if (anchorMinX === 0.5 && anchorMinY === 1) return AnchorPreset.BottomCenter;
if (anchorMinX === 1 && anchorMinY === 1) return AnchorPreset.BottomRight;
if (anchorMinX === 0 && anchorMinY === 0) return AnchorPreset.BottomLeft;
if (anchorMinX === 0.5 && anchorMinY === 0) return AnchorPreset.BottomCenter;
if (anchorMinX === 1 && anchorMinY === 0) return AnchorPreset.BottomRight;
}
return '';
};
const handlePresetSelect = (preset: AnchorPreset) => {
// [anchorMinX, anchorMinY, anchorMaxX, anchorMaxY]
// Y-up 坐标系Y=1 是顶部Y=0 是底部
// Y-up coordinate system: Y=1 is top, Y=0 is bottom
const presetValues: Record<AnchorPreset, [number, number, number, number]> = {
[AnchorPreset.TopLeft]: [0, 0, 0, 0],
[AnchorPreset.TopCenter]: [0.5, 0, 0.5, 0],
[AnchorPreset.TopRight]: [1, 0, 1, 0],
[AnchorPreset.TopLeft]: [0, 1, 0, 1],
[AnchorPreset.TopCenter]: [0.5, 1, 0.5, 1],
[AnchorPreset.TopRight]: [1, 1, 1, 1],
[AnchorPreset.MiddleLeft]: [0, 0.5, 0, 0.5],
[AnchorPreset.MiddleCenter]: [0.5, 0.5, 0.5, 0.5],
[AnchorPreset.MiddleRight]: [1, 0.5, 1, 0.5],
[AnchorPreset.BottomLeft]: [0, 1, 0, 1],
[AnchorPreset.BottomCenter]: [0.5, 1, 0.5, 1],
[AnchorPreset.BottomRight]: [1, 1, 1, 1],
[AnchorPreset.BottomLeft]: [0, 0, 0, 0],
[AnchorPreset.BottomCenter]: [0.5, 0, 0.5, 0],
[AnchorPreset.BottomRight]: [1, 0, 1, 0],
// Horizontal stretch | 水平拉伸
[AnchorPreset.StretchTop]: [0, 1, 1, 1],
[AnchorPreset.StretchMiddle]: [0, 0.5, 1, 0.5],
[AnchorPreset.StretchBottom]: [0, 0, 1, 0],
// Vertical stretch | 垂直拉伸
[AnchorPreset.StretchLeft]: [0, 0, 0, 1],
[AnchorPreset.StretchCenter]: [0.5, 0, 0.5, 1],
[AnchorPreset.StretchRight]: [1, 0, 1, 1],
// Full stretch | 完全拉伸
[AnchorPreset.StretchAll]: [0, 0, 1, 1],
};

View File

@@ -1 +1,2 @@
export * from './UITransformInspector';
export * from './UIRenderInspector';

View File

@@ -33,6 +33,7 @@
"@esengine/ecs-framework": "workspace:*",
"@esengine/engine-core": "workspace:*",
"@esengine/ecs-engine-bindgen": "workspace:*",
"@esengine/material-system": "workspace:*",
"@esengine/build-config": "workspace:*",
"rimraf": "^5.0.5",
"tsup": "^8.0.0",

View File

@@ -8,6 +8,9 @@ import { UIButtonComponent } from './components/widgets/UIButtonComponent';
import { UIProgressBarComponent } from './components/widgets/UIProgressBarComponent';
import { UISliderComponent } from './components/widgets/UISliderComponent';
import { UIScrollViewComponent } from './components/widgets/UIScrollViewComponent';
import { UIToggleComponent, type UIToggleStyle } from './components/widgets/UIToggleComponent';
import { UIInputFieldComponent, type UIInputContentType, type UIInputLineType } from './components/widgets/UIInputFieldComponent';
import { UIDropdownComponent, type UIDropdownOption } from './components/widgets/UIDropdownComponent';
/**
* 基础 UI 配置
@@ -125,6 +128,55 @@ export interface UIScrollViewConfig extends UIBaseConfig {
backgroundColor?: number;
}
/**
* 开关配置
* Toggle configuration
*/
export interface UIToggleConfig extends UIBaseConfig {
isOn?: boolean;
style?: UIToggleStyle;
onColor?: number;
offColor?: number;
onChange?: (isOn: boolean) => void;
}
/**
* 输入框配置
* Input field configuration
*/
export interface UIInputFieldConfig extends UIBaseConfig {
placeholder?: string;
text?: string;
contentType?: UIInputContentType;
lineType?: UIInputLineType;
characterLimit?: number;
textColor?: number;
placeholderColor?: number;
backgroundColor?: number;
borderColor?: number;
borderWidth?: number;
padding?: number;
onValueChanged?: (value: string) => void;
onSubmit?: (value: string) => void;
}
/**
* 下拉菜单配置
* Dropdown configuration
*/
export interface UIDropdownConfig extends UIBaseConfig {
options?: UIDropdownOption[];
selectedIndex?: number;
placeholder?: string;
buttonColor?: number;
textColor?: number;
borderColor?: number;
listBackgroundColor?: number;
optionHeight?: number;
maxVisibleOptions?: number;
onValueChanged?: (value: string | number, index: number) => void;
}
/**
* UI 构建器
* UI Builder - Simplified API for creating UI elements
@@ -390,6 +442,129 @@ export class UIBuilder {
return entity;
}
/**
* 创建开关
* Create toggle (checkbox/switch)
*/
public toggle(config: UIToggleConfig): Entity {
const entity = this.createBase({
...config,
width: config.width ?? (config.style === 'switch' ? 50 : 24),
height: config.height ?? 24
}, 'Toggle');
// 渲染组件
const render = entity.addComponent(new UIRenderComponent());
render.type = UIRenderType.Rect;
// 交互组件
const interactable = entity.addComponent(new UIInteractableComponent());
interactable.cursor = 'pointer';
// 开关组件
const toggle = entity.addComponent(new UIToggleComponent());
toggle.isOn = config.isOn ?? false;
toggle.style = config.style ?? 'checkbox';
toggle.onChange = config.onChange;
if (config.onColor !== undefined) toggle.onColor = config.onColor;
if (config.offColor !== undefined) toggle.offColor = config.offColor;
// 初始化显示状态
toggle.displayProgress = toggle.isOn ? 1 : 0;
toggle.targetProgress = toggle.displayProgress;
return entity;
}
/**
* 创建文本输入框
* Create input field
*/
public inputField(config: UIInputFieldConfig): Entity {
const entity = this.createBase({
...config,
width: config.width ?? 200,
height: config.height ?? 36
}, 'InputField');
// 渲染组件
const render = entity.addComponent(new UIRenderComponent());
render.type = UIRenderType.Rect;
render.backgroundColor = config.backgroundColor ?? 0xFFFFFF;
// 交互组件
const interactable = entity.addComponent(new UIInteractableComponent());
interactable.cursor = 'text';
interactable.focusable = true;
// 输入框组件
const inputField = entity.addComponent(new UIInputFieldComponent());
inputField.placeholder = config.placeholder ?? '';
inputField.text = config.text ?? '';
inputField.contentType = config.contentType ?? 'standard';
inputField.lineType = config.lineType ?? 'singleLine';
inputField.characterLimit = config.characterLimit ?? 0;
inputField.onValueChanged = config.onValueChanged;
inputField.onSubmit = config.onSubmit;
if (config.textColor !== undefined) inputField.textColor = config.textColor;
if (config.placeholderColor !== undefined) inputField.placeholderColor = config.placeholderColor;
if (config.padding !== undefined) inputField.padding = config.padding;
// 背景和边框通过 UIRenderComponent 设置
// Background and border are set via UIRenderComponent
if (config.backgroundColor !== undefined) render.backgroundColor = config.backgroundColor;
if (config.borderColor !== undefined) render.borderColor = config.borderColor;
if (config.borderWidth !== undefined) render.borderWidth = config.borderWidth;
return entity;
}
/**
* 创建下拉菜单
* Create dropdown
*/
public dropdown(config: UIDropdownConfig): Entity {
const entity = this.createBase({
...config,
width: config.width ?? 200,
height: config.height ?? 36
}, 'Dropdown');
// 渲染组件
const render = entity.addComponent(new UIRenderComponent());
render.type = UIRenderType.Rect;
render.backgroundColor = config.buttonColor ?? 0xFFFFFF;
// 交互组件
const interactable = entity.addComponent(new UIInteractableComponent());
interactable.cursor = 'pointer';
// 下拉菜单组件
const dropdown = entity.addComponent(new UIDropdownComponent());
dropdown.placeholder = config.placeholder ?? 'Select...';
dropdown.selectedIndex = config.selectedIndex ?? -1;
dropdown.onValueChanged = config.onValueChanged;
if (config.options) {
dropdown.options = config.options;
}
if (config.buttonColor !== undefined) dropdown.buttonColor = config.buttonColor;
if (config.textColor !== undefined) dropdown.textColor = config.textColor;
if (config.borderColor !== undefined) dropdown.borderColor = config.borderColor;
if (config.listBackgroundColor !== undefined) dropdown.listBackgroundColor = config.listBackgroundColor;
if (config.optionHeight !== undefined) dropdown.optionHeight = config.optionHeight;
if (config.maxVisibleOptions !== undefined) dropdown.maxVisibleOptions = config.maxVisibleOptions;
// 初始化颜色
dropdown.currentColor = dropdown.buttonColor;
dropdown.targetColor = dropdown.buttonColor;
return entity;
}
/**
* 创建分隔线
* Create divider/separator

View File

@@ -1,7 +1,9 @@
import type { IScene, IComponentRegistry } from '@esengine/ecs-framework';
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
import { EngineBridgeToken } from '@esengine/ecs-engine-bindgen';
import { EngineIntegration } from '@esengine/asset-system';
import { initializeDynamicAtlasService, registerTexturePathMapping, AtlasExpansionStrategy, type IAtlasEngineBridge } from './atlas';
import {
UITransformComponent,
UIRenderComponent,
@@ -11,13 +13,18 @@ import {
UIButtonComponent,
UIProgressBarComponent,
UISliderComponent,
UIScrollViewComponent
UIScrollViewComponent,
UIToggleComponent,
UIInputFieldComponent,
UIDropdownComponent
} from './components';
import { TextBlinkComponent } from './components/TextBlinkComponent';
import { SceneLoadTriggerComponent } from './components/SceneLoadTriggerComponent';
import { UIShinyEffectComponent } from './components/UIShinyEffectComponent';
import { UILayoutSystem } from './systems/UILayoutSystem';
import { UIInputSystem } from './systems/UIInputSystem';
import { UIAnimationSystem } from './systems/UIAnimationSystem';
import { UISliderFillSystem } from './systems/UISliderFillSystem';
import { UIRenderDataProvider } from './systems/UIRenderDataProvider';
import { TextBlinkSystem } from './systems/TextBlinkSystem';
import { SceneLoadTriggerSystem } from './systems/SceneLoadTriggerSystem';
@@ -28,7 +35,11 @@ import {
UIButtonRenderSystem,
UIProgressBarRenderSystem,
UISliderRenderSystem,
UIScrollViewRenderSystem
UIScrollViewRenderSystem,
UIToggleRenderSystem,
UIInputFieldRenderSystem,
UIDropdownRenderSystem,
UIShinyEffectSystem
} from './systems/render';
import {
UILayoutSystemToken,
@@ -56,14 +67,23 @@ class UIRuntimeModule implements IRuntimeModule {
registry.register(UIProgressBarComponent);
registry.register(UISliderComponent);
registry.register(UIScrollViewComponent);
registry.register(UIToggleComponent);
registry.register(UIInputFieldComponent);
registry.register(UIDropdownComponent);
registry.register(TextBlinkComponent);
registry.register(SceneLoadTriggerComponent);
registry.register(UIShinyEffectComponent);
}
createSystems(scene: IScene, context: SystemContext): void {
// 从服务注册表获取依赖 | Get dependencies from service registry
const engineBridge = context.services.get(EngineBridgeToken);
// Slider fill control system (runs before layout to modify anchors)
// 滑块填充控制系统(在布局之前运行以修改锚点)
const sliderFillSystem = new UISliderFillSystem();
scene.addSystem(sliderFillSystem);
const layoutSystem = new UILayoutSystem();
scene.addSystem(layoutSystem);
@@ -81,6 +101,11 @@ class UIRuntimeModule implements IRuntimeModule {
const renderBeginSystem = new UIRenderBeginSystem();
scene.addSystem(renderBeginSystem);
// Shiny effect system (runs before render systems to apply material overrides)
// 闪光效果系统(在渲染系统之前运行以应用材质覆盖)
const shinyEffectSystem = new UIShinyEffectSystem();
scene.addSystem(shinyEffectSystem);
const rectRenderSystem = new UIRectRenderSystem();
scene.addSystem(rectRenderSystem);
@@ -96,13 +121,46 @@ class UIRuntimeModule implements IRuntimeModule {
const buttonRenderSystem = new UIButtonRenderSystem();
scene.addSystem(buttonRenderSystem);
const toggleRenderSystem = new UIToggleRenderSystem();
scene.addSystem(toggleRenderSystem);
const inputFieldRenderSystem = new UIInputFieldRenderSystem();
scene.addSystem(inputFieldRenderSystem);
const dropdownRenderSystem = new UIDropdownRenderSystem();
scene.addSystem(dropdownRenderSystem);
const textRenderSystem = new UITextRenderSystem();
scene.addSystem(textRenderSystem);
if (engineBridge) {
// 设置文本渲染系统的纹理回调
// Set texture callback for text render system
textRenderSystem.setTextureCallback((id: number, dataUrl: string) => {
engineBridge.loadTexture(id, dataUrl);
});
// 设置纹理就绪检查回调,用于检测异步加载的纹理是否已就绪
// Set texture ready checker callback to detect if async-loaded texture is ready
if (engineBridge.isTextureReady) {
textRenderSystem.setTextureReadyChecker((id: number) => {
return engineBridge.isTextureReady!(id);
});
}
// 设置输入框渲染系统的纹理回调
// Set texture callback for input field render system
inputFieldRenderSystem.setTextureCallback((id: number, dataUrl: string) => {
engineBridge.loadTexture(id, dataUrl);
});
// 设置输入框渲染系统的纹理就绪检查回调
// Set texture ready checker callback for input field render system
if (engineBridge.isTextureReady) {
inputFieldRenderSystem.setTextureReadyChecker((id: number) => {
return engineBridge.isTextureReady!(id);
});
}
}
const uiRenderProvider = new UIRenderDataProvider();
@@ -115,6 +173,53 @@ class UIRuntimeModule implements IRuntimeModule {
context.services.register(UIRenderProviderToken, uiRenderProvider);
context.services.register(UIInputSystemToken, inputSystem);
context.services.register(UITextRenderSystemToken, textRenderSystem);
// 初始化动态图集服务 | Initialize dynamic atlas service
// 需要 engineBridge 支持 createBlankTexture 和 updateTextureRegion
// Requires engineBridge to support createBlankTexture and updateTextureRegion
console.log('[UIRuntimeModule] engineBridge available:', !!engineBridge);
console.log('[UIRuntimeModule] createBlankTexture:', !!engineBridge?.createBlankTexture);
console.log('[UIRuntimeModule] updateTextureRegion:', !!engineBridge?.updateTextureRegion);
if (engineBridge?.createBlankTexture && engineBridge?.updateTextureRegion) {
// 创建适配器将 EngineBridge 适配为 IAtlasEngineBridge
// Create adapter to adapt EngineBridge to IAtlasEngineBridge
const atlasBridge: IAtlasEngineBridge = {
createBlankTexture: (width: number, height: number) => {
return engineBridge.createBlankTexture!(width, height);
},
updateTextureRegion: (
id: number,
x: number,
y: number,
width: number,
height: number,
pixels: Uint8Array
) => {
engineBridge.updateTextureRegion!(id, x, y, width, height, pixels);
}
};
console.log('[UIRuntimeModule] Initializing dynamic atlas service...');
initializeDynamicAtlasService(atlasBridge, {
expansionStrategy: AtlasExpansionStrategy.Fixed, // 运行时默认使用固定模式 | Runtime defaults to fixed mode
initialPageSize: 256, // 动态模式起始大小 | Dynamic mode initial size
fixedPageSize: 1024, // 固定模式页面大小 | Fixed mode page size
maxPageSize: 2048, // 最大页面大小 | Max page size
maxPages: 4,
maxTextureSize: 512,
padding: 1
});
console.log('[UIRuntimeModule] Dynamic atlas service initialized');
// 注册纹理加载回调,当纹理通过 EngineIntegration 加载时自动注册路径映射
// Register texture load callback to automatically register path mapping
// when textures are loaded through EngineIntegration
EngineIntegration.onTextureLoad((guid: string, path: string, _textureId: number) => {
registerTexturePathMapping(guid, path);
});
} else {
console.warn('[UIRuntimeModule] Cannot initialize dynamic atlas service: engineBridge missing createBlankTexture or updateTextureRegion');
}
}
}
@@ -132,7 +237,9 @@ const manifest: ModuleManifest = {
canContainContent: true,
dependencies: ['core', 'math'],
exports: { components: ['UICanvasComponent'] },
editorPackage: '@esengine/ui-editor'
editorPackage: '@esengine/ui-editor',
// Plugin export for runtime loading | 运行时加载的插件导出
pluginExport: 'UIPlugin'
};
export const UIPlugin: IRuntimePlugin = {

View File

@@ -0,0 +1,280 @@
/**
* Bin Packing Algorithm for Dynamic Atlas
* 动态图集的矩形打包算法
*
* Implements the MaxRects algorithm for efficiently packing rectangles
* into a larger texture atlas.
* 实现 MaxRects 算法,高效地将矩形打包到更大的纹理图集中。
*/
/**
* A rectangle region within the atlas
* 图集内的矩形区域
*/
export interface PackedRect {
/** X position in atlas | 图集中的X位置 */
x: number;
/** Y position in atlas | 图集中的Y位置 */
y: number;
/** Width of the packed rectangle | 打包矩形的宽度 */
width: number;
/** Height of the packed rectangle | 打包矩形的高度 */
height: number;
}
/**
* MaxRects Bin Packer
* MaxRects 矩形打包器
*
* Uses the MaxRects algorithm with Best Short Side Fit heuristic
* to pack rectangles into a fixed-size bin (atlas texture).
* 使用带有最佳短边适配启发式的 MaxRects 算法
* 将矩形打包到固定大小的容器(图集纹理)中。
*/
export class BinPacker {
/** Atlas width | 图集宽度 */
private readonly binWidth: number;
/** Atlas height | 图集高度 */
private readonly binHeight: number;
/** Padding between packed rectangles | 打包矩形之间的间距 */
private readonly padding: number;
/**
* List of free rectangles available for packing
* 可用于打包的空闲矩形列表
*/
private freeRects: PackedRect[];
/**
* Create a new bin packer
* 创建新的矩形打包器
*
* @param width - Bin width (atlas texture width) | 容器宽度(图集纹理宽度)
* @param height - Bin height (atlas texture height) | 容器高度(图集纹理高度)
* @param padding - Padding between packed rectangles (default: 1) | 矩形之间的间距默认1
*/
constructor(width: number, height: number, padding: number = 1) {
this.binWidth = width;
this.binHeight = height;
this.padding = padding;
// Start with one free rectangle covering the entire bin
// 从覆盖整个容器的一个空闲矩形开始
this.freeRects = [{ x: 0, y: 0, width, height }];
}
/**
* Pack a rectangle into the atlas
* 将矩形打包到图集中
*
* @param width - Rectangle width | 矩形宽度
* @param height - Rectangle height | 矩形高度
* @returns Packed position, or null if no space available | 打包位置,如果没有可用空间则返回 null
*/
pack(width: number, height: number): PackedRect | null {
// Add padding | 添加间距
const paddedWidth = width + this.padding;
const paddedHeight = height + this.padding;
// Find best position using Best Short Side Fit
// 使用最佳短边适配查找最佳位置
const bestNode = this.findBestPosition(paddedWidth, paddedHeight);
if (!bestNode) {
return null; // No space available | 没有可用空间
}
// Place the rectangle | 放置矩形
const packedRect: PackedRect = {
x: bestNode.x,
y: bestNode.y,
width,
height
};
// Split free rectangles | 分割空闲矩形
this.splitFreeRects(bestNode.x, bestNode.y, paddedWidth, paddedHeight);
// Remove redundant free rectangles | 移除冗余的空闲矩形
this.pruneFreeRects();
return packedRect;
}
/**
* Find the best position for a rectangle using Best Short Side Fit
* 使用最佳短边适配查找矩形的最佳位置
*/
private findBestPosition(width: number, height: number): PackedRect | null {
let bestNode: PackedRect | null = null;
let bestShortSideFit = Infinity;
let bestLongSideFit = Infinity;
for (const freeRect of this.freeRects) {
// Check if rectangle fits | 检查矩形是否适合
if (width <= freeRect.width && height <= freeRect.height) {
const leftoverHoriz = Math.abs(freeRect.width - width);
const leftoverVert = Math.abs(freeRect.height - height);
const shortSideFit = Math.min(leftoverHoriz, leftoverVert);
const longSideFit = Math.max(leftoverHoriz, leftoverVert);
if (shortSideFit < bestShortSideFit ||
(shortSideFit === bestShortSideFit && longSideFit < bestLongSideFit)) {
bestNode = {
x: freeRect.x,
y: freeRect.y,
width,
height
};
bestShortSideFit = shortSideFit;
bestLongSideFit = longSideFit;
}
}
}
return bestNode;
}
/**
* Split free rectangles after placing a new rectangle
* 放置新矩形后分割空闲矩形
*/
private splitFreeRects(x: number, y: number, width: number, height: number): void {
const newFreeRects: PackedRect[] = [];
const usedRect: PackedRect = { x, y, width, height };
for (const freeRect of this.freeRects) {
// Check if the used rectangle intersects with this free rectangle
// 检查已使用矩形是否与此空闲矩形相交
if (!this.intersects(usedRect, freeRect)) {
newFreeRects.push(freeRect);
continue;
}
// Split the free rectangle into up to 4 new rectangles
// 将空闲矩形分割成最多4个新矩形
// Left piece | 左侧部分
if (usedRect.x > freeRect.x) {
newFreeRects.push({
x: freeRect.x,
y: freeRect.y,
width: usedRect.x - freeRect.x,
height: freeRect.height
});
}
// Right piece | 右侧部分
if (usedRect.x + usedRect.width < freeRect.x + freeRect.width) {
newFreeRects.push({
x: usedRect.x + usedRect.width,
y: freeRect.y,
width: freeRect.x + freeRect.width - usedRect.x - usedRect.width,
height: freeRect.height
});
}
// Bottom piece | 底部部分
if (usedRect.y > freeRect.y) {
newFreeRects.push({
x: freeRect.x,
y: freeRect.y,
width: freeRect.width,
height: usedRect.y - freeRect.y
});
}
// Top piece | 顶部部分
if (usedRect.y + usedRect.height < freeRect.y + freeRect.height) {
newFreeRects.push({
x: freeRect.x,
y: usedRect.y + usedRect.height,
width: freeRect.width,
height: freeRect.y + freeRect.height - usedRect.y - usedRect.height
});
}
}
this.freeRects = newFreeRects;
}
/**
* Remove redundant free rectangles (those contained within others)
* 移除冗余的空闲矩形(被其他矩形包含的)
*/
private pruneFreeRects(): void {
const pruned: PackedRect[] = [];
for (let i = 0; i < this.freeRects.length; i++) {
let isContained = false;
for (let j = 0; j < this.freeRects.length; j++) {
if (i !== j && this.contains(this.freeRects[j], this.freeRects[i])) {
isContained = true;
break;
}
}
if (!isContained) {
pruned.push(this.freeRects[i]);
}
}
this.freeRects = pruned;
}
/**
* Check if two rectangles intersect
* 检查两个矩形是否相交
*/
private intersects(a: PackedRect, b: PackedRect): boolean {
return a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y;
}
/**
* Check if rectangle a contains rectangle b
* 检查矩形 a 是否包含矩形 b
*/
private contains(a: PackedRect, b: PackedRect): boolean {
return a.x <= b.x &&
a.y <= b.y &&
a.x + a.width >= b.x + b.width &&
a.y + a.height >= b.y + b.height;
}
/**
* Get the current occupancy ratio of the bin
* 获取容器的当前占用率
*/
getOccupancy(): number {
let usedArea = this.binWidth * this.binHeight;
for (const freeRect of this.freeRects) {
usedArea -= freeRect.width * freeRect.height;
}
return usedArea / (this.binWidth * this.binHeight);
}
/**
* Check if the bin is full (no more space for small allocations)
* 检查容器是否已满(没有更多空间用于小分配)
*/
isFull(): boolean {
// Consider full if we can't fit a 16x16 texture
// 如果无法容纳 16x16 纹理,则认为已满
return this.freeRects.length === 0 ||
this.freeRects.every(r => r.width < 16 || r.height < 16);
}
/**
* Reset the packer to initial state
* 将打包器重置为初始状态
*/
reset(): void {
this.freeRects = [{ x: 0, y: 0, width: this.binWidth, height: this.binHeight }];
}
}

View File

@@ -0,0 +1,669 @@
/**
* Dynamic Atlas Manager
* 动态图集管理器
*
* Manages runtime texture atlasing to enable batching of UI elements
* that use different source textures.
* 管理运行时纹理图集,以启用使用不同源纹理的 UI 元素的合批。
*/
import { BinPacker, PackedRect } from './BinPacker';
/**
* Atlas expansion strategy
* 图集扩展策略
*/
export enum AtlasExpansionStrategy {
/**
* Dynamic expansion: Start small, expand pages when full (has rebuild cost)
* 动态扩展:从小尺寸开始,页面满时扩展(有重建开销)
*/
Dynamic = 'dynamic',
/**
* Fixed size: Use fixed page size, create new pages when full (no rebuild)
* 固定大小:使用固定页面大小,满时创建新页面(无重建)
*/
Fixed = 'fixed'
}
/**
* Stored texture data for rebuild during expansion
* 存储的纹理数据,用于扩展时重建
*/
interface StoredTexture {
guid: string;
pixels: Uint8Array;
width: number;
height: number;
}
/**
* Atlas entry storing the mapping from original texture to atlas region
* 图集条目,存储从原始纹理到图集区域的映射
*/
export interface AtlasEntry {
/** Atlas texture ID | 图集纹理ID */
atlasId: number;
/** Position in atlas | 图集中的位置 */
region: PackedRect;
/** Original texture width | 原始纹理宽度 */
originalWidth: number;
/** Original texture height | 原始纹理高度 */
originalHeight: number;
/** UV coordinates in atlas [u0, v0, u1, v1] | 图集中的UV坐标 */
uv: [number, number, number, number];
}
/**
* A single atlas texture with its packer
* 单个图集纹理及其打包器
*/
interface AtlasPage {
/** GPU texture ID | GPU纹理ID */
textureId: number;
/** Bin packer for this page | 此页面的矩形打包器 */
packer: BinPacker;
/** Atlas width | 图集宽度 */
width: number;
/** Atlas height | 图集高度 */
height: number;
}
/**
* Engine bridge interface for texture operations
* 纹理操作的引擎桥接接口
*/
export interface IAtlasEngineBridge {
/** Create a blank texture | 创建空白纹理 */
createBlankTexture(width: number, height: number): number;
/** Update a region of a texture | 更新纹理区域 */
updateTextureRegion(
id: number,
x: number,
y: number,
width: number,
height: number,
pixels: Uint8Array
): void;
}
/**
* Configuration for the dynamic atlas manager
* 动态图集管理器配置
*/
export interface DynamicAtlasConfig {
/**
* Expansion strategy (default: Fixed)
* 扩展策略(默认:固定)
*
* - Dynamic: Start small (initialPageSize), expand when full. Better memory efficiency but has rebuild cost.
* - Fixed: Use fixedPageSize directly, create new pages when full. No rebuild cost but uses more memory initially.
*
* - 动态从小尺寸开始initialPageSize满时扩展。内存效率更高但有重建开销。
* - 固定:直接使用 fixedPageSize满时创建新页面。无重建开销但初始内存占用更大。
*/
expansionStrategy?: AtlasExpansionStrategy;
/** Initial atlas page size for dynamic mode (default: 256) | 动态模式的初始页面大小默认256 */
initialPageSize?: number;
/** Fixed atlas page size for fixed mode (default: 1024) | 固定模式的页面大小默认1024 */
fixedPageSize?: number;
/** Maximum atlas page size (default: 2048) | 最大图集页面大小默认2048 */
maxPageSize?: number;
/** Maximum number of atlas pages (default: 4) | 最大图集页数默认4 */
maxPages?: number;
/** Maximum individual texture size to atlas (default: 512) | 可加入图集的最大单个纹理尺寸默认512 */
maxTextureSize?: number;
/** Padding between textures (default: 1) | 纹理之间的间距默认1 */
padding?: number;
}
/**
* Dynamic Atlas Manager
* 动态图集管理器
*
* Automatically packs individual textures into larger atlas textures
* at runtime to enable draw call batching.
* 在运行时自动将单个纹理打包到更大的图集纹理中,以启用绘制调用合批。
*
* @example
* ```typescript
* const manager = new DynamicAtlasManager(bridge);
*
* // Add texture to atlas
* const entry = await manager.addTexture('texture-guid', imageData, 64, 64);
*
* // Use atlas texture ID and remapped UV for rendering
* const atlasTextureId = entry.atlasId;
* const atlasUV = entry.uv;
* ```
*/
export class DynamicAtlasManager {
/** Engine bridge for texture operations | 纹理操作的引擎桥接 */
private bridge: IAtlasEngineBridge;
/** Atlas configuration | 图集配置 */
private config: {
expansionStrategy: AtlasExpansionStrategy;
initialPageSize: number;
fixedPageSize: number;
maxPageSize: number;
maxPages: number;
maxTextureSize: number;
padding: number;
};
/** Atlas pages | 图集页面 */
private pages: AtlasPage[] = [];
/** Mapping from texture GUID to atlas entry | 纹理GUID到图集条目的映射 */
private entries: Map<string, AtlasEntry> = new Map();
/** Stored textures for rebuild during expansion (only used in Dynamic mode) */
/** 存储的纹理数据,用于扩展时重建(仅在动态模式下使用) */
private storedTextures: Map<string, StoredTexture> = new Map();
/** Whether the manager has been initialized | 管理器是否已初始化 */
private initialized = false;
/**
* Create a new dynamic atlas manager
* 创建新的动态图集管理器
*
* @param bridge - Engine bridge for texture operations | 纹理操作的引擎桥接
* @param config - Configuration options | 配置选项
*/
constructor(bridge: IAtlasEngineBridge, config: DynamicAtlasConfig = {}) {
this.bridge = bridge;
this.config = {
expansionStrategy: config.expansionStrategy ?? AtlasExpansionStrategy.Fixed,
initialPageSize: config.initialPageSize ?? 256,
fixedPageSize: config.fixedPageSize ?? 1024,
maxPageSize: config.maxPageSize ?? 2048,
maxPages: config.maxPages ?? 4,
maxTextureSize: config.maxTextureSize ?? 512,
padding: config.padding ?? 1
};
}
/**
* Initialize the atlas manager (creates first atlas page)
* 初始化图集管理器(创建第一个图集页面)
*/
initialize(): void {
if (this.initialized) return;
// Choose initial page size based on strategy
// 根据策略选择初始页面大小
const initialSize = this.config.expansionStrategy === AtlasExpansionStrategy.Dynamic
? this.config.initialPageSize
: this.config.fixedPageSize;
console.log('[DynamicAtlasManager] Initializing with:', {
strategy: this.config.expansionStrategy,
initialPageSize: this.config.initialPageSize,
fixedPageSize: this.config.fixedPageSize,
selectedSize: initialSize
});
this.createNewPage(initialSize);
this.initialized = true;
}
/**
* Check if a texture is already in the atlas
* 检查纹理是否已在图集中
*
* @param textureGuid - Texture GUID | 纹理GUID
*/
hasTexture(textureGuid: string): boolean {
return this.entries.has(textureGuid);
}
/**
* Get atlas entry for a texture
* 获取纹理的图集条目
*
* @param textureGuid - Texture GUID | 纹理GUID
*/
getEntry(textureGuid: string): AtlasEntry | undefined {
return this.entries.get(textureGuid);
}
/**
* Add a texture to the atlas
* 将纹理添加到图集
*
* @param textureGuid - Unique identifier for this texture | 此纹理的唯一标识符
* @param pixels - RGBA pixel data | RGBA像素数据
* @param width - Texture width | 纹理宽度
* @param height - Texture height | 纹理高度
* @returns Atlas entry with UV mapping, or null if texture too large | 带UV映射的图集条目如果纹理太大则返回null
*/
addTexture(
textureGuid: string,
pixels: Uint8Array,
width: number,
height: number
): AtlasEntry | null {
// Check if already added | 检查是否已添加
const existing = this.entries.get(textureGuid);
if (existing) {
return existing;
}
// Check if texture is too large for atlasing
// 检查纹理是否太大无法加入图集
if (width > this.config.maxTextureSize || height > this.config.maxTextureSize) {
return null; // Too large, should use original texture | 太大,应使用原始纹理
}
// Ensure initialized | 确保已初始化
if (!this.initialized) {
this.initialize();
}
// Store texture data for potential rebuild (only in Dynamic mode)
// 存储纹理数据用于可能的重建(仅在动态模式下)
if (this.config.expansionStrategy === AtlasExpansionStrategy.Dynamic) {
this.storedTextures.set(textureGuid, {
guid: textureGuid,
pixels: new Uint8Array(pixels), // Clone to avoid external mutation
width,
height
});
}
// Try to pack into existing pages
// 尝试打包到现有页面
for (const page of this.pages) {
const region = page.packer.pack(width, height);
if (region) {
// Upload to atlas texture | 上传到图集纹理
this.bridge.updateTextureRegion(
page.textureId,
region.x,
region.y,
width,
height,
pixels
);
// Calculate UV coordinates | 计算UV坐标
const entry = this.createEntry(page, region, width, height);
this.entries.set(textureGuid, entry);
return entry;
}
}
// No space in existing pages
// 现有页面没有空间
if (this.config.expansionStrategy === AtlasExpansionStrategy.Dynamic) {
// Dynamic mode: Try to expand existing page first
// 动态模式:先尝试扩展现有页面
const expanded = this.tryExpandPage(0); // Try to expand first page
if (expanded) {
// Page expanded, try to pack again
// 页面已扩展,再次尝试打包
const page = this.pages[0];
const region = page.packer.pack(width, height);
if (region) {
this.bridge.updateTextureRegion(
page.textureId,
region.x,
region.y,
width,
height,
pixels
);
const entry = this.createEntry(page, region, width, height);
this.entries.set(textureGuid, entry);
return entry;
}
}
}
// Create new page if allowed
// 如果允许则创建新页面
if (this.pages.length < this.config.maxPages) {
// Calculate page size based on strategy
// 根据策略计算页面大小
let newPageSize: number;
if (this.config.expansionStrategy === AtlasExpansionStrategy.Fixed) {
newPageSize = this.config.fixedPageSize;
} else {
// Dynamic mode: start with initial size for new page
// 动态模式:新页面从初始大小开始
newPageSize = this.config.initialPageSize;
while (newPageSize < Math.max(width, height) + this.config.padding * 2) {
newPageSize *= 2;
if (newPageSize > this.config.maxPageSize) {
newPageSize = this.config.maxPageSize;
break;
}
}
}
const page = this.createNewPage(newPageSize);
const region = page.packer.pack(width, height);
if (region) {
this.bridge.updateTextureRegion(
page.textureId,
region.x,
region.y,
width,
height,
pixels
);
const entry = this.createEntry(page, region, width, height);
this.entries.set(textureGuid, entry);
return entry;
}
}
// Could not fit texture (all pages full or texture too large)
// 无法容纳纹理(所有页面已满或纹理太大)
return null;
}
/**
* Try to expand a page to a larger size (Dynamic mode only)
* 尝试将页面扩展到更大尺寸(仅动态模式)
*
* @param pageIndex - Index of the page to expand | 要扩展的页面索引
* @returns True if expansion succeeded | 如果扩展成功返回true
*/
private tryExpandPage(pageIndex: number): boolean {
const page = this.pages[pageIndex];
if (!page) return false;
// Check if already at max size
// 检查是否已达到最大尺寸
if (page.width >= this.config.maxPageSize) {
return false;
}
// Calculate new size (double the current size)
// 计算新尺寸(当前尺寸的两倍)
const newSize = Math.min(page.width * 2, this.config.maxPageSize);
// Create new texture
// 创建新纹理
const newTextureId = this.bridge.createBlankTexture(newSize, newSize);
// Create new packer
// 创建新打包器
const newPacker = new BinPacker(newSize, newSize, this.config.padding);
// Collect all textures from this page
// 收集此页面的所有纹理
const texturesInPage: StoredTexture[] = [];
for (const [guid, entry] of this.entries) {
if (entry.atlasId === page.textureId) {
const stored = this.storedTextures.get(guid);
if (stored) {
texturesInPage.push(stored);
}
}
}
// Sort by size (larger first for better packing)
// 按大小排序(大的优先以获得更好的打包效果)
texturesInPage.sort((a, b) => (b.width * b.height) - (a.width * a.height));
// Repack all textures into the new larger page
// 将所有纹理重新打包到新的更大页面
const newEntries = new Map<string, AtlasEntry>();
for (const tex of texturesInPage) {
const region = newPacker.pack(tex.width, tex.height);
if (!region) {
// Failed to repack (shouldn't happen if new size is larger)
// 重新打包失败(如果新尺寸更大则不应发生)
return false;
}
// Upload texture to new atlas
// 将纹理上传到新图集
this.bridge.updateTextureRegion(
newTextureId,
region.x,
region.y,
tex.width,
tex.height,
tex.pixels
);
// Calculate new UV coordinates
// 计算新的UV坐标
const u0 = region.x / newSize;
const v0 = region.y / newSize;
const u1 = (region.x + region.width) / newSize;
const v1 = (region.y + region.height) / newSize;
newEntries.set(tex.guid, {
atlasId: newTextureId,
region,
originalWidth: tex.width,
originalHeight: tex.height,
uv: [u0, v0, u1, v1]
});
}
// Update page
// 更新页面
page.textureId = newTextureId;
page.packer = newPacker;
page.width = newSize;
page.height = newSize;
// Update entries
// 更新条目
for (const [guid, entry] of newEntries) {
this.entries.set(guid, entry);
}
return true;
}
/**
* Create a new atlas page
* 创建新的图集页面
*
* @param size - Page size (default: initialPageSize) | 页面大小默认initialPageSize
*/
private createNewPage(size?: number): AtlasPage {
const pageSize = size ?? this.config.initialPageSize;
const textureId = this.bridge.createBlankTexture(pageSize, pageSize);
const page: AtlasPage = {
textureId,
packer: new BinPacker(pageSize, pageSize, this.config.padding),
width: pageSize,
height: pageSize
};
this.pages.push(page);
return page;
}
/**
* Create an atlas entry with UV coordinates
* 创建带UV坐标的图集条目
*/
private createEntry(
page: AtlasPage,
region: PackedRect,
originalWidth: number,
originalHeight: number
): AtlasEntry {
// Calculate normalized UV coordinates | 计算归一化UV坐标
const u0 = region.x / page.width;
const v0 = region.y / page.height;
const u1 = (region.x + region.width) / page.width;
const v1 = (region.y + region.height) / page.height;
return {
atlasId: page.textureId,
region,
originalWidth,
originalHeight,
uv: [u0, v0, u1, v1]
};
}
/**
* Remap UV coordinates from original texture space to atlas space
* 将UV坐标从原始纹理空间重映射到图集空间
*
* @param entry - Atlas entry | 图集条目
* @param originalU0 - Original U0 | 原始U0
* @param originalV0 - Original V0 | 原始V0
* @param originalU1 - Original U1 | 原始U1
* @param originalV1 - Original V1 | 原始V1
* @returns Remapped UV coordinates [u0, v0, u1, v1] | 重映射的UV坐标
*/
remapUV(
entry: AtlasEntry,
originalU0: number,
originalV0: number,
originalU1: number,
originalV1: number
): [number, number, number, number] {
const [atlasU0, atlasV0, atlasU1, atlasV1] = entry.uv;
// Calculate the UV range in atlas space | 计算图集空间中的UV范围
const atlasURange = atlasU1 - atlasU0;
const atlasVRange = atlasV1 - atlasV0;
// Remap original UVs to atlas space | 将原始UV重映射到图集空间
const u0 = atlasU0 + originalU0 * atlasURange;
const v0 = atlasV0 + originalV0 * atlasVRange;
const u1 = atlasU0 + originalU1 * atlasURange;
const v1 = atlasV0 + originalV1 * atlasVRange;
return [u0, v0, u1, v1];
}
/**
* Get all atlas texture IDs
* 获取所有图集纹理ID
*/
getAtlasTextureIds(): number[] {
return this.pages.map(p => p.textureId);
}
/**
* Get statistics about atlas usage
* 获取图集使用统计信息
*/
getStats(): {
pageCount: number;
textureCount: number;
averageOccupancy: number;
} {
const occupancies = this.pages.map(p => p.packer.getOccupancy());
const avgOccupancy = occupancies.length > 0
? occupancies.reduce((a, b) => a + b, 0) / occupancies.length
: 0;
return {
pageCount: this.pages.length,
textureCount: this.entries.size,
averageOccupancy: avgOccupancy
};
}
/**
* Get all atlas entries with their GUID
* 获取所有图集条目及其 GUID
*/
getAllEntries(): Array<{ guid: string; entry: AtlasEntry }> {
const result: Array<{ guid: string; entry: AtlasEntry }> = [];
for (const [guid, entry] of this.entries) {
result.push({ guid, entry });
}
return result;
}
/**
* Get detailed info for each atlas page
* 获取每个图集页面的详细信息
*/
getPageDetails(): Array<{
pageIndex: number;
textureId: number;
width: number;
height: number;
occupancy: number;
entries: Array<{ guid: string; entry: AtlasEntry }>;
}> {
return this.pages.map((page, index) => {
// Find all entries in this page
// 查找此页面中的所有条目
const pageEntries: Array<{ guid: string; entry: AtlasEntry }> = [];
for (const [guid, entry] of this.entries) {
if (entry.atlasId === page.textureId) {
pageEntries.push({ guid, entry });
}
}
return {
pageIndex: index,
textureId: page.textureId,
width: page.width,
height: page.height,
occupancy: page.packer.getOccupancy(),
entries: pageEntries
};
});
}
/**
* Clear all atlas data and reset
* 清除所有图集数据并重置
*
* Note: This does NOT delete GPU textures. Call this when switching scenes
* or when textures are no longer needed.
* 注意这不会删除GPU纹理。在切换场景或不再需要纹理时调用此方法。
*/
clear(): void {
this.entries.clear();
this.storedTextures.clear();
this.pages = [];
this.initialized = false;
}
/**
* Get current expansion strategy
* 获取当前扩展策略
*/
getExpansionStrategy(): AtlasExpansionStrategy {
return this.config.expansionStrategy;
}
}
// Singleton instance for global access
// 单例实例用于全局访问
let globalAtlasManager: DynamicAtlasManager | null = null;
/**
* Get the global dynamic atlas manager instance
* 获取全局动态图集管理器实例
*
* @param bridge - Engine bridge (required on first call) | 引擎桥接(首次调用时必需)
*/
export function getDynamicAtlasManager(bridge?: IAtlasEngineBridge): DynamicAtlasManager | null {
if (!globalAtlasManager && bridge) {
globalAtlasManager = new DynamicAtlasManager(bridge);
}
return globalAtlasManager;
}
/**
* Set the global dynamic atlas manager instance
* 设置全局动态图集管理器实例
*/
export function setDynamicAtlasManager(manager: DynamicAtlasManager | null): void {
globalAtlasManager = manager;
}

Some files were not shown because too many files have changed in this diff Show More