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:
663
docs/architecture/material-system-refactor.md
Normal file
663
docs/architecture/material-system-refactor.md
Normal 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. **渐进迁移**:可分阶段进行,不影响现有功能
|
||||
@@ -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
|
||||
* 按类型查找资产
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
* 批量加载纹理
|
||||
|
||||
103
packages/asset-system/src/interfaces/IAssetFileLoader.ts
Normal file
103
packages/asset-system/src/interfaces/IAssetFileLoader.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
139
packages/asset-system/src/services/AssetMetadataService.ts
Normal file
139
packages/asset-system/src/services/AssetMetadataService.ts
Normal 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 };
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,6 +28,7 @@ export {
|
||||
getSystemTypeName,
|
||||
getSystemInstanceTypeName,
|
||||
getSystemMetadata,
|
||||
getSystemInstanceMetadata,
|
||||
SYSTEM_TYPE_NAME
|
||||
} from './TypeDecorators';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
* 销毁桥接并释放资源。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = materialId(1: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可见性。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 }
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Material Inspector components.
|
||||
* 材质 Inspector 组件。
|
||||
*/
|
||||
|
||||
export { MaterialPropertiesEditor } from './MaterialPropertiesEditor';
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "材质实体"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,3 +2,4 @@ export * from './AssetFieldEditor';
|
||||
export * from './VectorFieldEditors';
|
||||
export * from './ColorFieldEditor';
|
||||
export * from './AnimationClipsFieldEditor';
|
||||
export * from './EntityRefFieldEditor';
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '可加入图集的最大单个纹理尺寸,超过此尺寸的纹理将不会被合批'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 设计分辨率
|
||||
|
||||
149
packages/editor-app/src/services/EditorAssetFileLoader.ts
Normal file
149
packages/editor-app/src/services/EditorAssetFileLoader.ts
Normal 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:\...")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
262
packages/editor-core/src/Gizmos/GizmoHitTester.ts
Normal file
262
packages/editor-core/src/Gizmos/GizmoHitTester.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,3 +10,4 @@
|
||||
|
||||
export * from './IGizmoProvider';
|
||||
export * from './GizmoRegistry';
|
||||
export * from './GizmoHitTester';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
302
packages/editor-core/src/Services/GizmoInteractionService.ts
Normal file
302
packages/editor-core/src/Services/GizmoInteractionService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] | NDC:Y向上,原点在中心
|
||||
/// - 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)
|
||||
};
|
||||
|
||||
@@ -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 | 清空批处理以供下一帧使用
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 作为 materialId(1: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) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
|
||||
189
packages/material-system/src/effects/BaseShinyEffect.ts
Normal file
189
packages/material-system/src/effects/BaseShinyEffect.ts
Normal 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;
|
||||
}
|
||||
153
packages/material-system/src/effects/ShinyEffectAnimator.ts
Normal file
153
packages/material-system/src/effects/ShinyEffectAnimator.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
176
packages/material-system/src/interfaces/IMaterialOverridable.ts
Normal file
176
packages/material-system/src/interfaces/IMaterialOverridable.ts
Normal 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 覆盖(RGBA,0.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;
|
||||
}
|
||||
369
packages/material-system/src/interfaces/IShaderProperty.ts
Normal file
369
packages/material-system/src/interfaces/IShaderProperty.ts
Normal 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;
|
||||
}
|
||||
268
packages/material-system/src/mixins/MaterialOverridableMixin.ts
Normal file
268
packages/material-system/src/mixins/MaterialOverridableMixin.ts
Normal 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 覆盖(RGBA,0.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;
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,8 @@ export const BuiltInShaders = {
|
||||
Grayscale: 1,
|
||||
Tint: 2,
|
||||
Flash: 3,
|
||||
Outline: 4
|
||||
Outline: 4,
|
||||
Shiny: 5
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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方法应正确反射向量', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
175
packages/sprite/src/ShinyEffectComponent.ts
Normal file
175
packages/sprite/src/ShinyEffectComponent.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 | 服务令牌
|
||||
|
||||
46
packages/sprite/src/systems/ShinyEffectSystem.ts
Normal file
46
packages/sprite/src/systems/ShinyEffectSystem.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
852
packages/ui-editor/src/inspectors/UIRenderInspector.tsx
Normal file
852
packages/ui-editor/src/inspectors/UIRenderInspector.tsx
Normal 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}`
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './UITransformInspector';
|
||||
export * from './UIRenderInspector';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
280
packages/ui/src/atlas/BinPacker.ts
Normal file
280
packages/ui/src/atlas/BinPacker.ts
Normal 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 }];
|
||||
}
|
||||
}
|
||||
669
packages/ui/src/atlas/DynamicAtlasManager.ts
Normal file
669
packages/ui/src/atlas/DynamicAtlasManager.ts
Normal 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
Reference in New Issue
Block a user