feat(material): 新增材质系统和着色器编辑器
This commit is contained in:
48
packages/material-editor/package.json
Normal file
48
packages/material-editor/package.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "@esengine/material-editor",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Editor components for material system",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"module": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"build:watch": "tsup --watch",
|
||||||
|
"type-check": "tsc --noEmit",
|
||||||
|
"clean": "rimraf dist"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@esengine/material-system": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@esengine/ecs-framework": "workspace:*",
|
||||||
|
"@esengine/engine-core": "workspace:*",
|
||||||
|
"@esengine/editor-core": "workspace:*",
|
||||||
|
"@esengine/build-config": "workspace:*",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"lucide-react": "^0.545.0",
|
||||||
|
"zustand": "^5.0.8",
|
||||||
|
"rimraf": "^5.0.5",
|
||||||
|
"tsup": "^8.0.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"ecs",
|
||||||
|
"material",
|
||||||
|
"shader",
|
||||||
|
"editor"
|
||||||
|
],
|
||||||
|
"author": "yhh",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
291
packages/material-editor/src/components/MaterialEditorPanel.tsx
Normal file
291
packages/material-editor/src/components/MaterialEditorPanel.tsx
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
/**
|
||||||
|
* Material Editor Panel
|
||||||
|
* 材质编辑器面板
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useCallback, useState } from 'react';
|
||||||
|
import { Core } from '@esengine/ecs-framework';
|
||||||
|
import { MessageHub, IFileSystemService } from '@esengine/editor-core';
|
||||||
|
import { BlendMode, BuiltInShaders } from '@esengine/material-system';
|
||||||
|
import { useMaterialEditorStore, createDefaultMaterialData } from '../stores/MaterialEditorStore';
|
||||||
|
import { Save, RefreshCw, FolderOpen } from 'lucide-react';
|
||||||
|
import '../styles/MaterialEditorPanel.css';
|
||||||
|
|
||||||
|
// 文件系统类型
|
||||||
|
type IFileSystem = {
|
||||||
|
readFile(path: string): Promise<string>;
|
||||||
|
writeFile(path: string, content: string): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 混合模式选项
|
||||||
|
*/
|
||||||
|
const BLEND_MODE_OPTIONS = [
|
||||||
|
{ value: BlendMode.None, label: 'None (Opaque)', labelZh: '无 (不透明)' },
|
||||||
|
{ value: BlendMode.Alpha, label: 'Alpha Blend', labelZh: 'Alpha 混合' },
|
||||||
|
{ value: BlendMode.Additive, label: 'Additive', labelZh: '叠加' },
|
||||||
|
{ value: BlendMode.Multiply, label: 'Multiply', labelZh: '正片叠底' },
|
||||||
|
{ value: BlendMode.Screen, label: 'Screen', labelZh: '滤色' },
|
||||||
|
{ value: BlendMode.PremultipliedAlpha, label: 'Premultiplied Alpha', labelZh: '预乘 Alpha' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内置着色器选项
|
||||||
|
*/
|
||||||
|
const BUILT_IN_SHADER_OPTIONS = [
|
||||||
|
{ value: BuiltInShaders.DefaultSprite, label: 'Default Sprite', labelZh: '默认精灵' },
|
||||||
|
{ value: BuiltInShaders.Grayscale, label: 'Grayscale', labelZh: '灰度' },
|
||||||
|
{ value: BuiltInShaders.Tint, label: 'Tint', labelZh: '着色' },
|
||||||
|
{ value: BuiltInShaders.Flash, label: 'Flash', labelZh: '闪烁' },
|
||||||
|
{ value: BuiltInShaders.Outline, label: 'Outline', labelZh: '描边' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Custom shader indicator value. | 自定义着色器指示值。 */
|
||||||
|
const CUSTOM_SHADER_VALUE = -1;
|
||||||
|
|
||||||
|
interface MaterialEditorPanelProps {
|
||||||
|
locale?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MaterialEditorPanel({ locale = 'en' }: MaterialEditorPanelProps) {
|
||||||
|
const {
|
||||||
|
currentFilePath,
|
||||||
|
pendingFilePath,
|
||||||
|
materialData,
|
||||||
|
isDirty,
|
||||||
|
isLoading,
|
||||||
|
setPendingFilePath,
|
||||||
|
setCurrentFilePath,
|
||||||
|
setMaterialData,
|
||||||
|
setLoading,
|
||||||
|
updateMaterialProperty,
|
||||||
|
} = useMaterialEditorStore();
|
||||||
|
|
||||||
|
const isZh = locale === 'zh';
|
||||||
|
|
||||||
|
// 加载材质文件
|
||||||
|
const loadMaterialFile = useCallback(async (filePath: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null;
|
||||||
|
if (!fileSystem) {
|
||||||
|
console.error('[MaterialEditor] FileSystem service not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fileSystem.readFile(filePath);
|
||||||
|
const data = JSON.parse(content);
|
||||||
|
setMaterialData(data);
|
||||||
|
setCurrentFilePath(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MaterialEditor] Failed to load material:', error);
|
||||||
|
// 如果加载失败,创建默认材质
|
||||||
|
const fileName = filePath.split(/[\\/]/).pop()?.replace(/\.mat$/i, '') || 'New Material';
|
||||||
|
setMaterialData(createDefaultMaterialData(fileName));
|
||||||
|
setCurrentFilePath(filePath);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [setLoading, setMaterialData, setCurrentFilePath]);
|
||||||
|
|
||||||
|
// 保存材质文件
|
||||||
|
const saveMaterialFile = useCallback(async () => {
|
||||||
|
if (!currentFilePath || !materialData) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null;
|
||||||
|
if (!fileSystem) {
|
||||||
|
console.error('[MaterialEditor] FileSystem service not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fileSystem.writeFile(currentFilePath, JSON.stringify(materialData, null, 2));
|
||||||
|
useMaterialEditorStore.getState().setDirty(false);
|
||||||
|
|
||||||
|
// 发送保存成功消息
|
||||||
|
const messageHub = Core.services.resolve(MessageHub);
|
||||||
|
messageHub?.publish('material:saved', { filePath: currentFilePath });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MaterialEditor] Failed to save material:', error);
|
||||||
|
}
|
||||||
|
}, [currentFilePath, materialData]);
|
||||||
|
|
||||||
|
// 处理 pendingFilePath
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingFilePath) {
|
||||||
|
loadMaterialFile(pendingFilePath);
|
||||||
|
setPendingFilePath(null);
|
||||||
|
}
|
||||||
|
}, [pendingFilePath, loadMaterialFile, setPendingFilePath]);
|
||||||
|
|
||||||
|
// 监听键盘快捷键
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
|
e.preventDefault();
|
||||||
|
saveMaterialFile();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [saveMaterialFile]);
|
||||||
|
|
||||||
|
// 渲染加载状态
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="material-editor-panel loading">
|
||||||
|
<RefreshCw className="spin" size={24} />
|
||||||
|
<span>{isZh ? '加载中...' : 'Loading...'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染空状态
|
||||||
|
if (!materialData) {
|
||||||
|
return (
|
||||||
|
<div className="material-editor-panel empty">
|
||||||
|
<span>{isZh ? '双击 .mat 文件打开材质编辑器' : 'Double-click a .mat file to open the material editor'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="material-editor-panel">
|
||||||
|
{/* 工具栏 */}
|
||||||
|
<div className="material-editor-toolbar">
|
||||||
|
<div className="toolbar-left">
|
||||||
|
<span className="material-name">{materialData.name}</span>
|
||||||
|
{isDirty && <span className="dirty-indicator">*</span>}
|
||||||
|
</div>
|
||||||
|
<div className="toolbar-right">
|
||||||
|
<button
|
||||||
|
className="toolbar-button"
|
||||||
|
onClick={saveMaterialFile}
|
||||||
|
disabled={!isDirty}
|
||||||
|
title={isZh ? '保存 (Ctrl+S)' : 'Save (Ctrl+S)'}
|
||||||
|
>
|
||||||
|
<Save size={16} />
|
||||||
|
<span>{isZh ? '保存' : 'Save'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 属性编辑区 */}
|
||||||
|
<div className="material-editor-content">
|
||||||
|
{/* 基本属性 */}
|
||||||
|
<div className="property-section">
|
||||||
|
<div className="section-header">{isZh ? '基本属性' : 'Basic Properties'}</div>
|
||||||
|
|
||||||
|
<div className="property-row">
|
||||||
|
<label>{isZh ? '名称' : 'Name'}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={materialData.name}
|
||||||
|
onChange={(e) => updateMaterialProperty('name', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="property-row">
|
||||||
|
<label>{isZh ? '着色器' : 'Shader'}</label>
|
||||||
|
<div className="shader-selector">
|
||||||
|
<select
|
||||||
|
value={typeof materialData.shader === 'string' ? CUSTOM_SHADER_VALUE : materialData.shader}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number(e.target.value);
|
||||||
|
if (value === CUSTOM_SHADER_VALUE) {
|
||||||
|
// Keep current custom shader path if already set
|
||||||
|
if (typeof materialData.shader !== 'string') {
|
||||||
|
updateMaterialProperty('shader', '');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateMaterialProperty('shader', value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{BUILT_IN_SHADER_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{isZh ? opt.labelZh : opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value={CUSTOM_SHADER_VALUE}>
|
||||||
|
{isZh ? '自定义着色器...' : 'Custom Shader...'}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom shader path input */}
|
||||||
|
{typeof materialData.shader === 'string' && (
|
||||||
|
<div className="property-row">
|
||||||
|
<label>{isZh ? '着色器路径' : 'Shader Path'}</label>
|
||||||
|
<div className="file-input-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={materialData.shader}
|
||||||
|
onChange={(e) => updateMaterialProperty('shader', e.target.value)}
|
||||||
|
placeholder={isZh ? '输入 .shader 文件路径' : 'Enter .shader file path'}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="browse-button"
|
||||||
|
onClick={async () => {
|
||||||
|
// TODO: Implement file browser dialog
|
||||||
|
// 这里可以集成编辑器的文件选择对话框
|
||||||
|
}}
|
||||||
|
title={isZh ? '浏览...' : 'Browse...'}
|
||||||
|
>
|
||||||
|
<FolderOpen size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="property-row">
|
||||||
|
<label>{isZh ? '混合模式' : 'Blend Mode'}</label>
|
||||||
|
<select
|
||||||
|
value={materialData.blendMode}
|
||||||
|
onChange={(e) => updateMaterialProperty('blendMode', Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{BLEND_MODE_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{isZh ? opt.labelZh : opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Uniform 参数 */}
|
||||||
|
<div className="property-section">
|
||||||
|
<div className="section-header">{isZh ? 'Uniform 参数' : 'Uniform Parameters'}</div>
|
||||||
|
|
||||||
|
{Object.keys(materialData.uniforms || {}).length === 0 ? (
|
||||||
|
<div className="empty-uniforms">
|
||||||
|
{isZh ? '该着色器没有自定义参数' : 'This shader has no custom parameters'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
Object.entries(materialData.uniforms || {}).map(([key, uniform]) => (
|
||||||
|
<div key={key} className="property-row">
|
||||||
|
<label>{key}</label>
|
||||||
|
<span className="uniform-type">{uniform.type}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 文件信息 */}
|
||||||
|
<div className="property-section">
|
||||||
|
<div className="section-header">{isZh ? '文件信息' : 'File Info'}</div>
|
||||||
|
<div className="property-row file-path">
|
||||||
|
<label>{isZh ? '路径' : 'Path'}</label>
|
||||||
|
<span title={currentFilePath || ''}>
|
||||||
|
{currentFilePath?.split(/[\\/]/).pop() || '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MaterialEditorPanel;
|
||||||
268
packages/material-editor/src/index.ts
Normal file
268
packages/material-editor/src/index.ts
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
/**
|
||||||
|
* @esengine/material-editor
|
||||||
|
*
|
||||||
|
* Editor support for @esengine/material-system - file creation templates and material asset management
|
||||||
|
*
|
||||||
|
* 材质编辑器模块 - 提供材质文件创建功能
|
||||||
|
* 注意:材质不是独立组件,而是作为渲染组件(如 SpriteComponent)的属性使用
|
||||||
|
* 材质文件 (.mat) 的预览和编辑在 Inspector 中完成
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||||
|
import { Core } from '@esengine/ecs-framework';
|
||||||
|
import type {
|
||||||
|
IEditorModuleLoader,
|
||||||
|
FileCreationTemplate,
|
||||||
|
IPlugin,
|
||||||
|
ModuleManifest
|
||||||
|
} from '@esengine/editor-core';
|
||||||
|
import {
|
||||||
|
MessageHub,
|
||||||
|
FileActionRegistry,
|
||||||
|
InspectorRegistry,
|
||||||
|
IInspectorRegistry,
|
||||||
|
IFileSystemService
|
||||||
|
} from '@esengine/editor-core';
|
||||||
|
|
||||||
|
// Inspector provider
|
||||||
|
import { MaterialAssetInspectorProvider } from './providers/MaterialAssetInspectorProvider';
|
||||||
|
|
||||||
|
// Runtime imports from @esengine/material-system
|
||||||
|
import {
|
||||||
|
MaterialRuntimeModule,
|
||||||
|
BlendMode,
|
||||||
|
BuiltInShaders
|
||||||
|
} from '@esengine/material-system';
|
||||||
|
|
||||||
|
// Editor components - for re-export only
|
||||||
|
import { MaterialEditorPanel } from './components/MaterialEditorPanel';
|
||||||
|
import { useMaterialEditorStore } from './stores/MaterialEditorStore';
|
||||||
|
|
||||||
|
// Import styles
|
||||||
|
import './styles/MaterialEditorPanel.css';
|
||||||
|
|
||||||
|
const DEFAULT_MATERIAL_TEMPLATE = {
|
||||||
|
name: 'New Material',
|
||||||
|
shader: BuiltInShaders.DefaultSprite,
|
||||||
|
blendMode: BlendMode.Alpha,
|
||||||
|
uniforms: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material Editor Module
|
||||||
|
*
|
||||||
|
* 提供:
|
||||||
|
* - 材质文件 (.mat) 创建模板
|
||||||
|
* - 着色器文件 (.shader) 创建模板
|
||||||
|
* - 材质资产创建消息处理(用于 PropertyInspector 中的创建按钮)
|
||||||
|
*
|
||||||
|
* 注意:.mat 文件的预览和编辑在 Inspector 中完成,不需要单独的编辑器面板
|
||||||
|
*/
|
||||||
|
export class MaterialEditorModule implements IEditorModuleLoader {
|
||||||
|
private inspectorProvider?: MaterialAssetInspectorProvider;
|
||||||
|
|
||||||
|
async install(services: ServiceContainer): Promise<void> {
|
||||||
|
// Register file creation templates
|
||||||
|
const fileActionRegistry = services.resolve(FileActionRegistry);
|
||||||
|
if (fileActionRegistry) {
|
||||||
|
for (const template of this.getFileCreationTemplates()) {
|
||||||
|
fileActionRegistry.registerCreationTemplate(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register asset creation mapping for .mat files
|
||||||
|
fileActionRegistry.registerAssetCreationMapping({
|
||||||
|
extension: '.mat',
|
||||||
|
createMessage: 'material:create',
|
||||||
|
canCreate: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register Material Asset Inspector Provider
|
||||||
|
const inspectorRegistry = services.resolve<InspectorRegistry>(IInspectorRegistry);
|
||||||
|
if (inspectorRegistry) {
|
||||||
|
this.inspectorProvider = new MaterialAssetInspectorProvider();
|
||||||
|
|
||||||
|
// Set up save handler using file system service
|
||||||
|
const fileSystem = services.tryResolve(IFileSystemService) as { writeFile(path: string, content: string): Promise<void> } | null;
|
||||||
|
if (fileSystem) {
|
||||||
|
this.inspectorProvider.setSaveHandler(async (path, content) => {
|
||||||
|
await fileSystem.writeFile(path, content);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
inspectorRegistry.register(this.inspectorProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to material:create message
|
||||||
|
const messageHub = services.resolve(MessageHub);
|
||||||
|
if (messageHub) {
|
||||||
|
messageHub.subscribe('material:create', async (payload: {
|
||||||
|
entityId?: string;
|
||||||
|
onChange?: (value: string | null) => void;
|
||||||
|
}) => {
|
||||||
|
await this.handleCreateMaterialAsset(payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async uninstall(): Promise<void> {
|
||||||
|
// Reset store state
|
||||||
|
useMaterialEditorStore.getState().reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
getFileCreationTemplates(): FileCreationTemplate[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'create-material',
|
||||||
|
label: 'Material',
|
||||||
|
extension: 'mat',
|
||||||
|
icon: 'Palette',
|
||||||
|
category: 'Rendering',
|
||||||
|
getContent: (fileName: string): string => {
|
||||||
|
const materialName = fileName.replace(/\.mat$/i, '');
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
...DEFAULT_MATERIAL_TEMPLATE,
|
||||||
|
name: materialName
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'create-shader',
|
||||||
|
label: 'Shader',
|
||||||
|
extension: 'shader',
|
||||||
|
icon: 'Code',
|
||||||
|
category: 'Rendering',
|
||||||
|
getContent: (fileName: string): string => {
|
||||||
|
const shaderName = fileName.replace(/\.shader$/i, '');
|
||||||
|
return JSON.stringify(
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
shader: {
|
||||||
|
name: shaderName,
|
||||||
|
vertexSource: `#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
in vec2 a_position;
|
||||||
|
in vec2 a_texCoord;
|
||||||
|
in vec4 a_color;
|
||||||
|
|
||||||
|
uniform mat4 u_projection;
|
||||||
|
uniform mat4 u_view;
|
||||||
|
|
||||||
|
out vec2 v_texCoord;
|
||||||
|
out vec4 v_color;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
gl_Position = u_projection * u_view * vec4(a_position, 0.0, 1.0);
|
||||||
|
v_texCoord = a_texCoord;
|
||||||
|
v_color = a_color;
|
||||||
|
}`,
|
||||||
|
fragmentSource: `#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
in vec2 v_texCoord;
|
||||||
|
in vec4 v_color;
|
||||||
|
|
||||||
|
uniform sampler2D u_texture;
|
||||||
|
|
||||||
|
out vec4 fragColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 texColor = texture(u_texture, v_texCoord);
|
||||||
|
fragColor = texColor * v_color;
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle create material asset request
|
||||||
|
*/
|
||||||
|
private async handleCreateMaterialAsset(
|
||||||
|
payload: { entityId?: string; onChange?: (value: string | null) => void }
|
||||||
|
): Promise<void> {
|
||||||
|
// Import dialog and file system services dynamically
|
||||||
|
const { IDialogService, IFileSystemService } = await import('@esengine/editor-core');
|
||||||
|
type IDialog = { saveDialog(options: any): Promise<string | null> };
|
||||||
|
type IFileSystem = { writeFile(path: string, content: string): Promise<void> };
|
||||||
|
|
||||||
|
const dialog = Core.services.tryResolve(IDialogService) as IDialog | null;
|
||||||
|
const fileSystem = Core.services.tryResolve(IFileSystemService) as IFileSystem | null;
|
||||||
|
|
||||||
|
if (!dialog || !fileSystem) {
|
||||||
|
console.error('[MaterialEditorModule] Dialog or FileSystem service not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = await dialog.saveDialog({
|
||||||
|
title: 'Create Material Asset',
|
||||||
|
filters: [{ name: 'Material', extensions: ['mat'] }],
|
||||||
|
defaultPath: 'new-material.mat'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const materialName = filePath.split(/[\\/]/).pop()?.replace(/\.mat$/i, '') || 'New Material';
|
||||||
|
const materialData = {
|
||||||
|
...DEFAULT_MATERIAL_TEMPLATE,
|
||||||
|
name: materialName
|
||||||
|
};
|
||||||
|
|
||||||
|
await fileSystem.writeFile(filePath, JSON.stringify(materialData, null, 2));
|
||||||
|
|
||||||
|
if (payload.onChange) {
|
||||||
|
payload.onChange(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const materialEditorModule = new MaterialEditorModule();
|
||||||
|
|
||||||
|
// Re-exports
|
||||||
|
export { MaterialEditorPanel } from './components/MaterialEditorPanel';
|
||||||
|
export { useMaterialEditorStore, createDefaultMaterialData } from './stores/MaterialEditorStore';
|
||||||
|
export type { MaterialEditorState } from './stores/MaterialEditorStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material Plugin Manifest
|
||||||
|
*/
|
||||||
|
const manifest: ModuleManifest = {
|
||||||
|
id: '@esengine/material-system',
|
||||||
|
name: '@esengine/material-system',
|
||||||
|
displayName: 'Material System',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'Material and shader system for custom rendering effects',
|
||||||
|
category: 'Rendering',
|
||||||
|
isCore: true,
|
||||||
|
defaultEnabled: true,
|
||||||
|
isEngineModule: true,
|
||||||
|
canContainContent: true,
|
||||||
|
dependencies: ['engine-core'],
|
||||||
|
exports: {
|
||||||
|
components: ['MaterialComponent'],
|
||||||
|
other: ['Material', 'Shader', 'BlendMode']
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete Material Plugin (runtime + editor)
|
||||||
|
*/
|
||||||
|
export const MaterialPlugin: IPlugin = {
|
||||||
|
manifest,
|
||||||
|
runtimeModule: new MaterialRuntimeModule(),
|
||||||
|
editorModule: materialEditorModule
|
||||||
|
};
|
||||||
|
|
||||||
|
export default materialEditorModule;
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
/**
|
||||||
|
* MaterialAssetInspectorProvider - Inspector provider for .mat files
|
||||||
|
* 材质资产检视器提供者 - 用于 .mat 文件的检视器
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import type { IInspectorProvider, InspectorContext } from '@esengine/editor-core';
|
||||||
|
import { BlendMode, BuiltInShaders, UniformType } from '@esengine/material-system';
|
||||||
|
import { Palette, Save, RotateCcw, Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
import '../styles/MaterialInspector.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asset file info interface (matches editor-app's AssetFileInfo)
|
||||||
|
*/
|
||||||
|
interface AssetFileInfo {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
extension?: string;
|
||||||
|
size?: number;
|
||||||
|
modified?: number;
|
||||||
|
isDirectory: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asset file target with content
|
||||||
|
*/
|
||||||
|
interface AssetFileTarget {
|
||||||
|
type: 'asset-file';
|
||||||
|
data: AssetFileInfo;
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UniformValue {
|
||||||
|
type: UniformType;
|
||||||
|
value: number | number[] | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MaterialData {
|
||||||
|
name: string;
|
||||||
|
shader: number | string;
|
||||||
|
blendMode?: BlendMode;
|
||||||
|
uniforms?: Record<string, UniformValue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLEND_MODE_OPTIONS = [
|
||||||
|
{ value: BlendMode.None, label: 'None (Opaque)' },
|
||||||
|
{ value: BlendMode.Alpha, label: 'Alpha' },
|
||||||
|
{ value: BlendMode.Additive, label: 'Additive' },
|
||||||
|
{ value: BlendMode.Multiply, label: 'Multiply' },
|
||||||
|
{ value: BlendMode.Screen, label: 'Screen' },
|
||||||
|
{ value: BlendMode.PremultipliedAlpha, label: 'Premultiplied Alpha' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const SHADER_OPTIONS = [
|
||||||
|
{ value: BuiltInShaders.DefaultSprite, label: 'Default Sprite' },
|
||||||
|
{ value: BuiltInShaders.Grayscale, label: 'Grayscale' },
|
||||||
|
{ value: BuiltInShaders.Tint, label: 'Tint' },
|
||||||
|
{ value: BuiltInShaders.Flash, label: 'Flash' },
|
||||||
|
{ value: BuiltInShaders.Outline, label: 'Outline' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const UNIFORM_TYPE_OPTIONS = [
|
||||||
|
{ value: UniformType.Float, label: 'Float' },
|
||||||
|
{ value: UniformType.Vec2, label: 'Vec2' },
|
||||||
|
{ value: UniformType.Vec3, label: 'Vec3' },
|
||||||
|
{ value: UniformType.Vec4, label: 'Vec4' },
|
||||||
|
{ value: UniformType.Color, label: 'Color' },
|
||||||
|
{ value: UniformType.Int, label: 'Int' }
|
||||||
|
];
|
||||||
|
|
||||||
|
interface MaterialInspectorViewProps {
|
||||||
|
fileInfo: AssetFileInfo;
|
||||||
|
content: string;
|
||||||
|
onSave?: (path: string, content: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MaterialInspectorView({ fileInfo, content, onSave }: MaterialInspectorViewProps) {
|
||||||
|
const [material, setMaterial] = useState<MaterialData | null>(null);
|
||||||
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [uniformsExpanded, setUniformsExpanded] = useState(true);
|
||||||
|
const [newUniformName, setNewUniformName] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
setMaterial(parsed);
|
||||||
|
setError(null);
|
||||||
|
setIsDirty(false);
|
||||||
|
} catch (e) {
|
||||||
|
setError('Failed to parse material file');
|
||||||
|
setMaterial(null);
|
||||||
|
}
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!material || !onSave) return;
|
||||||
|
try {
|
||||||
|
const jsonContent = JSON.stringify(material, null, 2);
|
||||||
|
await onSave(fileInfo.path, jsonContent);
|
||||||
|
setIsDirty(false);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save material:', e);
|
||||||
|
}
|
||||||
|
}, [material, fileInfo.path, onSave]);
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
setMaterial(parsed);
|
||||||
|
setIsDirty(false);
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
const updateMaterial = useCallback((updates: Partial<MaterialData>) => {
|
||||||
|
setMaterial(prev => prev ? { ...prev, ...updates } : null);
|
||||||
|
setIsDirty(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateUniform = useCallback((name: string, value: UniformValue) => {
|
||||||
|
setMaterial(prev => {
|
||||||
|
if (!prev) return null;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
uniforms: {
|
||||||
|
...prev.uniforms,
|
||||||
|
[name]: value
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setIsDirty(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeUniform = useCallback((name: string) => {
|
||||||
|
setMaterial(prev => {
|
||||||
|
if (!prev || !prev.uniforms) return prev;
|
||||||
|
const newUniforms = { ...prev.uniforms };
|
||||||
|
delete newUniforms[name];
|
||||||
|
return { ...prev, uniforms: newUniforms };
|
||||||
|
});
|
||||||
|
setIsDirty(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addUniform = useCallback(() => {
|
||||||
|
if (!newUniformName.trim()) return;
|
||||||
|
const name = newUniformName.trim();
|
||||||
|
setMaterial(prev => {
|
||||||
|
if (!prev) return null;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
uniforms: {
|
||||||
|
...prev.uniforms,
|
||||||
|
[name]: { type: UniformType.Float, value: 0 }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setNewUniformName('');
|
||||||
|
setIsDirty(true);
|
||||||
|
}, [newUniformName]);
|
||||||
|
|
||||||
|
const renderUniformEditor = (name: string, uniform: UniformValue) => {
|
||||||
|
const handleTypeChange = (newType: UniformType) => {
|
||||||
|
let defaultValue: number | number[] = 0;
|
||||||
|
switch (newType) {
|
||||||
|
case UniformType.Vec2:
|
||||||
|
defaultValue = [0, 0];
|
||||||
|
break;
|
||||||
|
case UniformType.Vec3:
|
||||||
|
defaultValue = [0, 0, 0];
|
||||||
|
break;
|
||||||
|
case UniformType.Vec4:
|
||||||
|
case UniformType.Color:
|
||||||
|
defaultValue = [1, 1, 1, 1];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
defaultValue = 0;
|
||||||
|
}
|
||||||
|
updateUniform(name, { type: newType, value: defaultValue });
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderValueEditor = () => {
|
||||||
|
switch (uniform.type) {
|
||||||
|
case UniformType.Float:
|
||||||
|
case UniformType.Int:
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="material-input"
|
||||||
|
value={uniform.value as number}
|
||||||
|
step={uniform.type === UniformType.Int ? 1 : 0.01}
|
||||||
|
onChange={(e) => updateUniform(name, { ...uniform, value: parseFloat(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case UniformType.Vec2:
|
||||||
|
case UniformType.Vec3:
|
||||||
|
case UniformType.Vec4: {
|
||||||
|
const values = uniform.value as number[];
|
||||||
|
const labels = ['X', 'Y', 'Z', 'W'];
|
||||||
|
return (
|
||||||
|
<div className="material-vec-editor">
|
||||||
|
{values.map((v, i) => (
|
||||||
|
<div key={i} className="material-vec-field">
|
||||||
|
<span className="material-vec-label">{labels[i]}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="material-input material-vec-input"
|
||||||
|
value={v}
|
||||||
|
step={0.01}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValues = [...values];
|
||||||
|
newValues[i] = parseFloat(e.target.value) || 0;
|
||||||
|
updateUniform(name, { ...uniform, value: newValues });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case UniformType.Color: {
|
||||||
|
const colorValues = uniform.value as number[];
|
||||||
|
const r = Math.round((colorValues[0] || 0) * 255);
|
||||||
|
const g = Math.round((colorValues[1] || 0) * 255);
|
||||||
|
const b = Math.round((colorValues[2] || 0) * 255);
|
||||||
|
const hexColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="material-color-editor">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="material-color-picker"
|
||||||
|
value={hexColor}
|
||||||
|
onChange={(e) => {
|
||||||
|
const hex = e.target.value;
|
||||||
|
const newR = parseInt(hex.slice(1, 3), 16) / 255;
|
||||||
|
const newG = parseInt(hex.slice(3, 5), 16) / 255;
|
||||||
|
const newB = parseInt(hex.slice(5, 7), 16) / 255;
|
||||||
|
updateUniform(name, { ...uniform, value: [newR, newG, newB, colorValues[3] || 1] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="material-input material-alpha-input"
|
||||||
|
value={colorValues[3] ?? 1}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
title="Alpha"
|
||||||
|
onChange={(e) => {
|
||||||
|
const newAlpha = parseFloat(e.target.value) || 0;
|
||||||
|
updateUniform(name, { ...uniform, value: [colorValues[0] ?? 1, colorValues[1] ?? 1, colorValues[2] ?? 1, newAlpha] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return <span className="material-value-text">{JSON.stringify(uniform.value)}</span>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={name} className="material-uniform-item">
|
||||||
|
<div className="material-uniform-header">
|
||||||
|
<span className="material-uniform-name">{name}</span>
|
||||||
|
<select
|
||||||
|
className="material-select material-type-select"
|
||||||
|
value={uniform.type}
|
||||||
|
onChange={(e) => handleTypeChange(e.target.value as UniformType)}
|
||||||
|
>
|
||||||
|
{UNIFORM_TYPE_OPTIONS.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="material-icon-btn material-delete-btn"
|
||||||
|
onClick={() => removeUniform(name)}
|
||||||
|
title="Remove uniform"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="material-uniform-value">
|
||||||
|
{renderValueEditor()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="entity-inspector">
|
||||||
|
<div className="inspector-header">
|
||||||
|
<Palette size={16} style={{ color: '#a78bfa' }} />
|
||||||
|
<span className="entity-name">{fileInfo.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="inspector-content">
|
||||||
|
<div className="material-error">{error}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!material) {
|
||||||
|
return (
|
||||||
|
<div className="entity-inspector">
|
||||||
|
<div className="inspector-header">
|
||||||
|
<Palette size={16} style={{ color: '#a78bfa' }} />
|
||||||
|
<span className="entity-name">{fileInfo.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="inspector-content">
|
||||||
|
<div className="material-loading">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="entity-inspector material-inspector">
|
||||||
|
<div className="inspector-header">
|
||||||
|
<Palette size={16} style={{ color: '#a78bfa' }} />
|
||||||
|
<span className="entity-name">{material.name || fileInfo.name}</span>
|
||||||
|
{isDirty && <span className="material-dirty-indicator">*</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="material-toolbar">
|
||||||
|
<button
|
||||||
|
className="material-toolbar-btn"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!isDirty || !onSave}
|
||||||
|
title="Save (Ctrl+S)"
|
||||||
|
>
|
||||||
|
<Save size={14} />
|
||||||
|
<span>Save</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="material-toolbar-btn"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={!isDirty}
|
||||||
|
title="Reset changes"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} />
|
||||||
|
<span>Reset</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inspector-content">
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div className="section-title">Basic Properties</div>
|
||||||
|
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="material-input"
|
||||||
|
value={material.name}
|
||||||
|
onChange={(e) => updateMaterial({ name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">Shader</label>
|
||||||
|
<select
|
||||||
|
className="material-select"
|
||||||
|
value={material.shader}
|
||||||
|
onChange={(e) => updateMaterial({ shader: parseInt(e.target.value) })}
|
||||||
|
>
|
||||||
|
{SHADER_OPTIONS.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">Blend Mode</label>
|
||||||
|
<select
|
||||||
|
className="material-select"
|
||||||
|
value={material.blendMode ?? BlendMode.Alpha}
|
||||||
|
onChange={(e) => updateMaterial({ blendMode: parseInt(e.target.value) as BlendMode })}
|
||||||
|
>
|
||||||
|
{BLEND_MODE_OPTIONS.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div
|
||||||
|
className="section-title section-title-collapsible"
|
||||||
|
onClick={() => setUniformsExpanded(!uniformsExpanded)}
|
||||||
|
>
|
||||||
|
{uniformsExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
<span>Uniforms</span>
|
||||||
|
<span className="material-uniform-count">
|
||||||
|
({Object.keys(material.uniforms || {}).length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{uniformsExpanded && (
|
||||||
|
<div className="material-uniforms-content">
|
||||||
|
{material.uniforms && Object.entries(material.uniforms).map(([name, uniform]) =>
|
||||||
|
renderUniformEditor(name, uniform)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="material-add-uniform">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="material-input"
|
||||||
|
placeholder="Uniform name..."
|
||||||
|
value={newUniformName}
|
||||||
|
onChange={(e) => setNewUniformName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && addUniform()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="material-icon-btn material-add-btn"
|
||||||
|
onClick={addUniform}
|
||||||
|
disabled={!newUniformName.trim()}
|
||||||
|
title="Add uniform"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material Asset Inspector Provider
|
||||||
|
*/
|
||||||
|
export class MaterialAssetInspectorProvider implements IInspectorProvider<AssetFileTarget> {
|
||||||
|
readonly id = 'material-asset-inspector';
|
||||||
|
readonly name = 'Material Asset Inspector';
|
||||||
|
readonly priority = 100;
|
||||||
|
|
||||||
|
private saveHandler?: (path: string, content: string) => Promise<void>;
|
||||||
|
|
||||||
|
setSaveHandler(handler: (path: string, content: string) => Promise<void>): void {
|
||||||
|
this.saveHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
canHandle(target: unknown): target is AssetFileTarget {
|
||||||
|
if (typeof target !== 'object' || target === null) return false;
|
||||||
|
const t = target as any;
|
||||||
|
return t.type === 'asset-file' &&
|
||||||
|
t.data?.extension?.toLowerCase() === 'mat' &&
|
||||||
|
typeof t.content === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
render(target: AssetFileTarget, _context: InspectorContext): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<MaterialInspectorView
|
||||||
|
fileInfo={target.data}
|
||||||
|
content={target.content!}
|
||||||
|
onSave={this.saveHandler}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
packages/material-editor/src/stores/MaterialEditorStore.ts
Normal file
55
packages/material-editor/src/stores/MaterialEditorStore.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { BlendMode, type MaterialDefinition } from '@esengine/material-system';
|
||||||
|
|
||||||
|
export interface MaterialEditorState {
|
||||||
|
currentFilePath: string | null;
|
||||||
|
pendingFilePath: string | null;
|
||||||
|
materialData: MaterialDefinition | null;
|
||||||
|
isDirty: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
|
||||||
|
setPendingFilePath: (path: string | null) => void;
|
||||||
|
setCurrentFilePath: (path: string | null) => void;
|
||||||
|
setMaterialData: (data: MaterialDefinition | null) => void;
|
||||||
|
setDirty: (dirty: boolean) => void;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
updateMaterialProperty: <K extends keyof MaterialDefinition>(key: K, value: MaterialDefinition[K]) => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
currentFilePath: null,
|
||||||
|
pendingFilePath: null,
|
||||||
|
materialData: null,
|
||||||
|
isDirty: false,
|
||||||
|
isLoading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useMaterialEditorStore = create<MaterialEditorState>((set) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
setPendingFilePath: (path) => set({ pendingFilePath: path }),
|
||||||
|
setCurrentFilePath: (path) => set({ currentFilePath: path }),
|
||||||
|
setMaterialData: (data) => set({ materialData: data, isDirty: false }),
|
||||||
|
setDirty: (dirty) => set({ isDirty: dirty }),
|
||||||
|
setLoading: (loading) => set({ isLoading: loading }),
|
||||||
|
|
||||||
|
updateMaterialProperty: (key, value) => set((state) => {
|
||||||
|
if (!state.materialData) return state;
|
||||||
|
return {
|
||||||
|
materialData: { ...state.materialData, [key]: value },
|
||||||
|
isDirty: true,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
reset: () => set(initialState),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function createDefaultMaterialData(name: string = 'New Material'): MaterialDefinition {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
shader: 0,
|
||||||
|
blendMode: BlendMode.Alpha,
|
||||||
|
uniforms: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
193
packages/material-editor/src/styles/MaterialEditorPanel.css
Normal file
193
packages/material-editor/src/styles/MaterialEditorPanel.css
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* Material Editor Panel Styles
|
||||||
|
* 材质编辑器面板样式
|
||||||
|
*/
|
||||||
|
|
||||||
|
.material-editor-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg-primary, #1e1e1e);
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-editor-panel.loading,
|
||||||
|
.material-editor-panel.empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-editor-panel.loading .spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.material-editor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #333);
|
||||||
|
background: var(--bg-secondary, #252526);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dirty-indicator {
|
||||||
|
color: var(--accent-color, #0078d4);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--button-bg, #333);
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-button:hover:not(:disabled) {
|
||||||
|
background: var(--button-hover-bg, #444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.material-editor-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Property Section */
|
||||||
|
.property-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 6px 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #333);
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-row label {
|
||||||
|
flex: 0 0 100px;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-row input,
|
||||||
|
.property-row select {
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--input-bg, #333);
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-row input:focus,
|
||||||
|
.property-row select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color, #0078d4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.property-row.file-path span {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uniform-type {
|
||||||
|
color: var(--text-tertiary, #666);
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-uniforms {
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shader Selector */
|
||||||
|
.shader-selector {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-selector select {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File Input Row */
|
||||||
|
.file-input-row {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input-row input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--button-bg, #333);
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-button:hover {
|
||||||
|
background: var(--button-hover-bg, #444);
|
||||||
|
}
|
||||||
263
packages/material-editor/src/styles/MaterialInspector.css
Normal file
263
packages/material-editor/src/styles/MaterialInspector.css
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
/* MaterialInspector Styles */
|
||||||
|
|
||||||
|
.material-inspector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-dirty-indicator {
|
||||||
|
color: #f59e0b;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #333);
|
||||||
|
background: var(--panel-bg, #1e1e1e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-toolbar-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border-color, #444);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--button-bg, #2d2d2d);
|
||||||
|
color: var(--text-color, #ccc);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-toolbar-btn:hover:not(:disabled) {
|
||||||
|
background: var(--button-hover-bg, #3d3d3d);
|
||||||
|
border-color: var(--accent-color, #0078d4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-toolbar-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border-color, #444);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--input-bg, #252525);
|
||||||
|
color: var(--text-color, #ccc);
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-input:focus {
|
||||||
|
border-color: var(--accent-color, #0078d4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-input::-webkit-inner-spin-button,
|
||||||
|
.material-input::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border-color, #444);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--input-bg, #252525);
|
||||||
|
color: var(--text-color, #ccc);
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-select:focus {
|
||||||
|
border-color: var(--accent-color, #0078d4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Uniforms section */
|
||||||
|
.section-title-collapsible {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title-collapsible:hover {
|
||||||
|
color: var(--accent-color, #0078d4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-uniform-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-uniforms-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-uniform-item {
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--item-bg, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-uniform-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-uniform-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-color, #ccc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-type-select {
|
||||||
|
width: auto;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-uniform-value {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vector editor */
|
||||||
|
.material-vec-editor {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-vec-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-vec-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #888;
|
||||||
|
width: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-vec-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color editor */
|
||||||
|
.material-color-editor {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-color-picker {
|
||||||
|
width: 40px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border-color, #444);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-color-picker::-webkit-color-swatch-wrapper {
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-color-picker::-webkit-color-swatch {
|
||||||
|
border-radius: 2px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-alpha-input {
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon buttons */
|
||||||
|
.material-icon-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-color, #888);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icon-btn:hover:not(:disabled) {
|
||||||
|
background: var(--button-hover-bg, #333);
|
||||||
|
color: var(--text-color, #ccc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icon-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-delete-btn:hover:not(:disabled) {
|
||||||
|
color: #ef4444;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-add-btn:hover:not(:disabled) {
|
||||||
|
color: #22c55e;
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add uniform row */
|
||||||
|
.material-add-uniform {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px dashed var(--border-color, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-add-uniform .material-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error and loading states */
|
||||||
|
.material-error {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-loading {
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-value-text {
|
||||||
|
font-family: Consolas, Monaco, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
8
packages/material-editor/tsconfig.build.json
Normal file
8
packages/material-editor/tsconfig.build.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": false,
|
||||||
|
"declarationMap": false
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||||
|
}
|
||||||
19
packages/material-editor/tsconfig.json
Normal file
19
packages/material-editor/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist",
|
||||||
|
"composite": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"],
|
||||||
|
"references": [
|
||||||
|
{ "path": "../ecs-framework" },
|
||||||
|
{ "path": "../engine-core" },
|
||||||
|
{ "path": "../material-system" },
|
||||||
|
{ "path": "../editor-core" }
|
||||||
|
]
|
||||||
|
}
|
||||||
7
packages/material-editor/tsup.config.ts
Normal file
7
packages/material-editor/tsup.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'tsup';
|
||||||
|
import { editorOnlyPreset } from '../build-config/src/presets/plugin-tsup';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
...editorOnlyPreset(),
|
||||||
|
tsconfig: 'tsconfig.build.json'
|
||||||
|
});
|
||||||
23
packages/material-system/module.json
Normal file
23
packages/material-system/module.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"id": "material-system",
|
||||||
|
"name": "@esengine/material-system",
|
||||||
|
"displayName": "Material System",
|
||||||
|
"description": "Material and shader system | 材质和着色器系统",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"category": "Rendering",
|
||||||
|
"icon": "Palette",
|
||||||
|
"tags": ["material", "shader", "rendering"],
|
||||||
|
"isCore": true,
|
||||||
|
"defaultEnabled": true,
|
||||||
|
"isEngineModule": true,
|
||||||
|
"canContainContent": true,
|
||||||
|
"platforms": ["web", "desktop"],
|
||||||
|
"dependencies": ["core", "asset-system"],
|
||||||
|
"exports": {
|
||||||
|
"other": ["Material", "Shader", "MaterialManager"]
|
||||||
|
},
|
||||||
|
"editorPackage": "@esengine/material-editor",
|
||||||
|
"requiresWasm": false,
|
||||||
|
"outputPath": "dist/index.js",
|
||||||
|
"pluginExport": "MaterialSystemPlugin"
|
||||||
|
}
|
||||||
48
packages/material-system/package.json
Normal file
48
packages/material-system/package.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "@esengine/material-system",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Material and shader system for ES Engine",
|
||||||
|
"esengine": {
|
||||||
|
"plugin": true,
|
||||||
|
"pluginExport": "MaterialSystemPlugin",
|
||||||
|
"category": "rendering",
|
||||||
|
"isEnginePlugin": true
|
||||||
|
},
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"module": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"build:watch": "tsup --watch",
|
||||||
|
"type-check": "tsc --noEmit",
|
||||||
|
"clean": "rimraf dist"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@esengine/ecs-framework": "workspace:*",
|
||||||
|
"@esengine/asset-system": "workspace:*",
|
||||||
|
"@esengine/engine-core": "workspace:*",
|
||||||
|
"@esengine/build-config": "workspace:*",
|
||||||
|
"rimraf": "^5.0.5",
|
||||||
|
"tsup": "^8.0.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"ecs",
|
||||||
|
"material",
|
||||||
|
"shader",
|
||||||
|
"webgl",
|
||||||
|
"rendering"
|
||||||
|
],
|
||||||
|
"author": "yhh",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
276
packages/material-system/src/Material.ts
Normal file
276
packages/material-system/src/Material.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
/**
|
||||||
|
* Material class for managing rendering properties.
|
||||||
|
* 用于管理渲染属性的材质类。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BlendMode, CullMode, UniformType, UniformValue, MaterialDefinition } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material class that holds shader reference and uniform values.
|
||||||
|
* 持有着色器引用和uniform值的材质类。
|
||||||
|
*/
|
||||||
|
export class Material {
|
||||||
|
/** Material ID assigned by the engine | 引擎分配的材质ID */
|
||||||
|
private _id: number = -1;
|
||||||
|
|
||||||
|
/** Material name for debugging | 材质名称(用于调试) */
|
||||||
|
public name: string;
|
||||||
|
|
||||||
|
/** Shader ID to use | 使用的着色器ID */
|
||||||
|
public shaderId: number;
|
||||||
|
|
||||||
|
/** Blend mode | 混合模式 */
|
||||||
|
public blendMode: BlendMode;
|
||||||
|
|
||||||
|
/** Cull mode | 剔除模式 */
|
||||||
|
public cullMode: CullMode;
|
||||||
|
|
||||||
|
/** Depth test enabled | 是否启用深度测试 */
|
||||||
|
public depthTest: boolean;
|
||||||
|
|
||||||
|
/** Depth write enabled | 是否启用深度写入 */
|
||||||
|
public depthWrite: boolean;
|
||||||
|
|
||||||
|
/** Material uniform values | 材质uniform值 */
|
||||||
|
private uniforms: Map<string, UniformValue> = new Map();
|
||||||
|
|
||||||
|
/** Texture references by uniform name | 按uniform名称引用的纹理 */
|
||||||
|
private textures: Map<string, string> = new Map();
|
||||||
|
|
||||||
|
/** Whether the material needs to sync with engine | 材质是否需要与引擎同步 */
|
||||||
|
private _dirty: boolean = true;
|
||||||
|
|
||||||
|
constructor(name: string = 'Material', shaderId: number = 0) {
|
||||||
|
this.name = name;
|
||||||
|
this.shaderId = shaderId;
|
||||||
|
this.blendMode = BlendMode.Alpha;
|
||||||
|
this.cullMode = CullMode.None;
|
||||||
|
this.depthTest = false;
|
||||||
|
this.depthWrite = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the material ID | 获取材质ID */
|
||||||
|
get id(): number {
|
||||||
|
return this._id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set the material ID (called by MaterialManager) | 设置材质ID(由MaterialManager调用) */
|
||||||
|
set id(value: number) {
|
||||||
|
this._id = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if material is dirty | 检查材质是否为脏 */
|
||||||
|
get dirty(): boolean {
|
||||||
|
return this._dirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark material as clean | 标记材质为干净 */
|
||||||
|
markClean(): void {
|
||||||
|
this._dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Uniform Setters =============
|
||||||
|
// ============= Uniform设置方法 =============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a float uniform.
|
||||||
|
* 设置浮点uniform。
|
||||||
|
*/
|
||||||
|
setFloat(name: string, value: number): this {
|
||||||
|
this.uniforms.set(name, { type: UniformType.Float, value });
|
||||||
|
this._dirty = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a vec2 uniform.
|
||||||
|
* 设置vec2 uniform。
|
||||||
|
*/
|
||||||
|
setVec2(name: string, x: number, y: number): this {
|
||||||
|
this.uniforms.set(name, { type: UniformType.Vec2, value: [x, y] });
|
||||||
|
this._dirty = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a vec3 uniform.
|
||||||
|
* 设置vec3 uniform。
|
||||||
|
*/
|
||||||
|
setVec3(name: string, x: number, y: number, z: number): this {
|
||||||
|
this.uniforms.set(name, { type: UniformType.Vec3, value: [x, y, z] });
|
||||||
|
this._dirty = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a vec4 uniform.
|
||||||
|
* 设置vec4 uniform。
|
||||||
|
*/
|
||||||
|
setVec4(name: string, x: number, y: number, z: number, w: number): this {
|
||||||
|
this.uniforms.set(name, { type: UniformType.Vec4, value: [x, y, z, w] });
|
||||||
|
this._dirty = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a color uniform (RGBA, 0.0-1.0).
|
||||||
|
* 设置颜色uniform(RGBA,0.0-1.0)。
|
||||||
|
*/
|
||||||
|
setColor(name: string, r: number, g: number, b: number, a: number = 1.0): this {
|
||||||
|
this.uniforms.set(name, { type: UniformType.Color, value: [r, g, b, a] });
|
||||||
|
this._dirty = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set an integer uniform.
|
||||||
|
* 设置整数uniform。
|
||||||
|
*/
|
||||||
|
setInt(name: string, value: number): this {
|
||||||
|
this.uniforms.set(name, { type: UniformType.Int, value: Math.floor(value) });
|
||||||
|
this._dirty = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a texture uniform.
|
||||||
|
* 设置纹理uniform。
|
||||||
|
*/
|
||||||
|
setTexture(name: string, texturePath: string): this {
|
||||||
|
this.textures.set(name, texturePath);
|
||||||
|
this._dirty = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Uniform Getters =============
|
||||||
|
// ============= Uniform获取方法 =============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a uniform value.
|
||||||
|
* 获取uniform值。
|
||||||
|
*/
|
||||||
|
getUniform(name: string): UniformValue | undefined {
|
||||||
|
return this.uniforms.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a texture path.
|
||||||
|
* 获取纹理路径。
|
||||||
|
*/
|
||||||
|
getTexture(name: string): string | undefined {
|
||||||
|
return this.textures.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all uniforms.
|
||||||
|
* 获取所有uniform。
|
||||||
|
*/
|
||||||
|
getUniforms(): Map<string, UniformValue> {
|
||||||
|
return this.uniforms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all textures.
|
||||||
|
* 获取所有纹理。
|
||||||
|
*/
|
||||||
|
getTextures(): Map<string, string> {
|
||||||
|
return this.textures;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Serialization =============
|
||||||
|
// ============= 序列化 =============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export to material definition.
|
||||||
|
* 导出为材质定义。
|
||||||
|
*/
|
||||||
|
toDefinition(): MaterialDefinition {
|
||||||
|
const uniformsObj: Record<string, UniformValue> = {};
|
||||||
|
for (const [key, value] of this.uniforms) {
|
||||||
|
uniformsObj[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const texturesObj: Record<string, string> = {};
|
||||||
|
for (const [key, value] of this.textures) {
|
||||||
|
texturesObj[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
shader: this.shaderId,
|
||||||
|
blendMode: this.blendMode,
|
||||||
|
cullMode: this.cullMode,
|
||||||
|
depthTest: this.depthTest,
|
||||||
|
depthWrite: this.depthWrite,
|
||||||
|
uniforms: Object.keys(uniformsObj).length > 0 ? uniformsObj : undefined,
|
||||||
|
textures: Object.keys(texturesObj).length > 0 ? texturesObj : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import from material definition.
|
||||||
|
* 从材质定义导入。
|
||||||
|
*/
|
||||||
|
static fromDefinition(def: MaterialDefinition): Material {
|
||||||
|
const material = new Material(def.name, typeof def.shader === 'number' ? def.shader : 0);
|
||||||
|
material.blendMode = def.blendMode ?? BlendMode.Alpha;
|
||||||
|
material.cullMode = def.cullMode ?? CullMode.None;
|
||||||
|
material.depthTest = def.depthTest ?? false;
|
||||||
|
material.depthWrite = def.depthWrite ?? false;
|
||||||
|
|
||||||
|
if (def.uniforms) {
|
||||||
|
for (const [key, value] of Object.entries(def.uniforms)) {
|
||||||
|
material.uniforms.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (def.textures) {
|
||||||
|
for (const [key, value] of Object.entries(def.textures)) {
|
||||||
|
material.textures.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return material;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Static Factory Methods =============
|
||||||
|
// ============= 静态工厂方法 =============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a default sprite material.
|
||||||
|
* 创建默认精灵材质。
|
||||||
|
*/
|
||||||
|
static sprite(): Material {
|
||||||
|
return new Material('Sprite', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an additive (glow) material.
|
||||||
|
* 创建加法(发光)材质。
|
||||||
|
*/
|
||||||
|
static additive(): Material {
|
||||||
|
const mat = new Material('Additive', 0);
|
||||||
|
mat.blendMode = BlendMode.Additive;
|
||||||
|
return mat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a multiply (shadow) material.
|
||||||
|
* 创建乘法(阴影)材质。
|
||||||
|
*/
|
||||||
|
static multiply(): Material {
|
||||||
|
const mat = new Material('Multiply', 0);
|
||||||
|
mat.blendMode = BlendMode.Multiply;
|
||||||
|
return mat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an unlit/opaque material.
|
||||||
|
* 创建无光照/不透明材质。
|
||||||
|
*/
|
||||||
|
static unlit(): Material {
|
||||||
|
const mat = new Material('Unlit', 0);
|
||||||
|
mat.blendMode = BlendMode.None;
|
||||||
|
return mat;
|
||||||
|
}
|
||||||
|
}
|
||||||
788
packages/material-system/src/MaterialManager.ts
Normal file
788
packages/material-system/src/MaterialManager.ts
Normal file
@@ -0,0 +1,788 @@
|
|||||||
|
/**
|
||||||
|
* Material manager service.
|
||||||
|
* 材质管理器服务。
|
||||||
|
*
|
||||||
|
* Manages materials and shaders for the rendering system.
|
||||||
|
* 管理渲染系统的材质和着色器。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Material } from './Material';
|
||||||
|
import {
|
||||||
|
Shader,
|
||||||
|
DEFAULT_VERTEX_SHADER,
|
||||||
|
DEFAULT_FRAGMENT_SHADER,
|
||||||
|
GRAYSCALE_FRAGMENT_SHADER,
|
||||||
|
TINT_FRAGMENT_SHADER,
|
||||||
|
FLASH_FRAGMENT_SHADER,
|
||||||
|
OUTLINE_FRAGMENT_SHADER
|
||||||
|
} from './Shader';
|
||||||
|
import { BuiltInMaterials, BuiltInShaders, UniformType } from './types';
|
||||||
|
import type { IAssetManager } from '@esengine/asset-system';
|
||||||
|
import { AssetType } from '@esengine/asset-system';
|
||||||
|
import { MaterialLoader, type IMaterialAssetData } from './loaders/MaterialLoader';
|
||||||
|
import { ShaderLoader, type IShaderAssetData } from './loaders/ShaderLoader';
|
||||||
|
import { createLogger } from '@esengine/ecs-framework';
|
||||||
|
|
||||||
|
/** Logger instance for MaterialManager. | MaterialManager的日志实例。 */
|
||||||
|
const logger = createLogger('MaterialManager');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Engine bridge interface for communicating with Rust engine.
|
||||||
|
* 与Rust引擎通信的引擎桥接接口。
|
||||||
|
*/
|
||||||
|
export interface IEngineBridge {
|
||||||
|
compileShader(vertexSource: string, fragmentSource: string): Promise<number>;
|
||||||
|
compileShaderWithId(shaderId: number, vertexSource: string, fragmentSource: string): Promise<void>;
|
||||||
|
hasShader(shaderId: number): boolean;
|
||||||
|
removeShader(shaderId: number): boolean;
|
||||||
|
|
||||||
|
createMaterial(name: string, shaderId: number, blendMode: number): number;
|
||||||
|
createMaterialWithId(materialId: number, name: string, shaderId: number, blendMode: number): void;
|
||||||
|
hasMaterial(materialId: number): boolean;
|
||||||
|
removeMaterial(materialId: number): boolean;
|
||||||
|
|
||||||
|
setMaterialFloat(materialId: number, name: string, value: number): boolean;
|
||||||
|
setMaterialVec2(materialId: number, name: string, x: number, y: number): boolean;
|
||||||
|
setMaterialVec3(materialId: number, name: string, x: number, y: number, z: number): boolean;
|
||||||
|
setMaterialVec4(materialId: number, name: string, x: number, y: number, z: number, w: number): boolean;
|
||||||
|
setMaterialColor(materialId: number, name: string, r: number, g: number, b: number, a: number): boolean;
|
||||||
|
setMaterialBlendMode(materialId: number, blendMode: number): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material manager service.
|
||||||
|
* 材质管理器服务。
|
||||||
|
*
|
||||||
|
* Manages materials, shaders, and their GPU resources.
|
||||||
|
* 管理材质、着色器及其GPU资源。
|
||||||
|
*/
|
||||||
|
export class MaterialManager {
|
||||||
|
/** Registered shaders. | 已注册的着色器。 */
|
||||||
|
private shaders: Map<number, Shader> = new Map();
|
||||||
|
|
||||||
|
/** Shader name to ID mapping. | 着色器名称到ID的映射。 */
|
||||||
|
private shaderNameToId: Map<string, number> = new Map();
|
||||||
|
|
||||||
|
/** Registered materials. | 已注册的材质。 */
|
||||||
|
private materials: Map<number, Material> = new Map();
|
||||||
|
|
||||||
|
/** Material name to ID mapping. | 材质名称到ID的映射。 */
|
||||||
|
private materialNameToId: Map<string, number> = new Map();
|
||||||
|
|
||||||
|
/** Material path to ID mapping. | 材质路径到ID的映射。 */
|
||||||
|
private materialPathToId: Map<string, number> = new Map();
|
||||||
|
|
||||||
|
/** Shader path to ID mapping. | 着色器路径到ID的映射。 */
|
||||||
|
private shaderPathToId: Map<string, number> = new Map();
|
||||||
|
|
||||||
|
/** Pending material loads (path -> promise). | 等待加载的材质(路径 -> Promise)。 */
|
||||||
|
private pendingMaterialLoads: Map<string, Promise<number>> = new Map();
|
||||||
|
|
||||||
|
/** Pending shader loads (path -> promise). | 等待加载的着色器(路径 -> Promise)。 */
|
||||||
|
private pendingShaderLoads: Map<string, Promise<number>> = new Map();
|
||||||
|
|
||||||
|
/** Next shader ID for custom shaders. | 下一个自定义着色器ID。 */
|
||||||
|
private nextShaderId: number = 100;
|
||||||
|
|
||||||
|
/** Next material ID for custom materials. | 下一个自定义材质ID。 */
|
||||||
|
private nextMaterialId: number = 100;
|
||||||
|
|
||||||
|
/** Engine bridge for GPU operations. | 用于GPU操作的引擎桥接。 */
|
||||||
|
private engineBridge: IEngineBridge | null = null;
|
||||||
|
|
||||||
|
/** Asset manager for loading material files. | 用于加载材质文件的资产管理器。 */
|
||||||
|
private assetManager: IAssetManager | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Register built-in shaders and materials.
|
||||||
|
// 注册内置着色器和材质。
|
||||||
|
this.registerBuiltInAssets();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the engine bridge for GPU operations.
|
||||||
|
* 设置用于GPU操作的引擎桥接。
|
||||||
|
*
|
||||||
|
* @param bridge - Engine bridge instance. | 引擎桥接实例。
|
||||||
|
*/
|
||||||
|
setEngineBridge(bridge: IEngineBridge): void {
|
||||||
|
this.engineBridge = bridge;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the asset manager for loading material files.
|
||||||
|
* 设置用于加载材质文件的资产管理器。
|
||||||
|
*
|
||||||
|
* Also registers Material and Shader loaders with the asset manager.
|
||||||
|
* 同时向资产管理器注册材质和着色器加载器。
|
||||||
|
*
|
||||||
|
* @param manager - Asset manager instance. | 资产管理器实例。
|
||||||
|
*/
|
||||||
|
setAssetManager(manager: IAssetManager): void {
|
||||||
|
this.assetManager = manager;
|
||||||
|
|
||||||
|
// Register loaders with asset manager.
|
||||||
|
// 向资产管理器注册加载器。
|
||||||
|
if (manager.registerLoader) {
|
||||||
|
manager.registerLoader(AssetType.Material, new MaterialLoader());
|
||||||
|
manager.registerLoader(AssetType.Shader, new ShaderLoader());
|
||||||
|
logger.info('Registered Material and Shader loaders');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerBuiltInAssets(): void {
|
||||||
|
// Built-in shaders
|
||||||
|
const builtInShaders = [
|
||||||
|
{ id: BuiltInShaders.DefaultSprite, name: 'DefaultSprite', vertex: DEFAULT_VERTEX_SHADER, fragment: DEFAULT_FRAGMENT_SHADER },
|
||||||
|
{ id: BuiltInShaders.Grayscale, name: 'Grayscale', vertex: DEFAULT_VERTEX_SHADER, fragment: GRAYSCALE_FRAGMENT_SHADER },
|
||||||
|
{ 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 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { id, name, vertex, fragment } of builtInShaders) {
|
||||||
|
const shader = new Shader(name, vertex, fragment);
|
||||||
|
shader.id = id;
|
||||||
|
this.shaders.set(id, shader);
|
||||||
|
this.shaderNameToId.set(name, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-in materials
|
||||||
|
const builtInMaterials = [
|
||||||
|
{ id: BuiltInMaterials.Default, material: Material.sprite() },
|
||||||
|
{ id: BuiltInMaterials.Additive, material: Material.additive() },
|
||||||
|
{ id: BuiltInMaterials.Multiply, material: Material.multiply() },
|
||||||
|
{ id: BuiltInMaterials.Unlit, material: Material.unlit() },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { id, material } of builtInMaterials) {
|
||||||
|
material.id = id;
|
||||||
|
this.materials.set(id, material);
|
||||||
|
this.materialNameToId.set(material.name, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Shader Management =============
|
||||||
|
// ============= 着色器管理 =============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a shader.
|
||||||
|
* 注册着色器。
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `shader` - Shader instance to register. | 要注册的着色器实例。
|
||||||
|
*
|
||||||
|
* # Returns | 返回
|
||||||
|
* Shader ID for referencing this shader. | 用于引用此着色器的ID。
|
||||||
|
*/
|
||||||
|
async registerShader(shader: Shader): Promise<number> {
|
||||||
|
const shaderId = this.nextShaderId++;
|
||||||
|
shader.id = shaderId;
|
||||||
|
|
||||||
|
// Compile on GPU if engine bridge is available
|
||||||
|
if (this.engineBridge) {
|
||||||
|
await this.engineBridge.compileShaderWithId(
|
||||||
|
shaderId,
|
||||||
|
shader.vertexSource,
|
||||||
|
shader.fragmentSource
|
||||||
|
);
|
||||||
|
shader.markCompiled();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.shaders.set(shaderId, shader);
|
||||||
|
this.shaderNameToId.set(shader.name, shaderId);
|
||||||
|
|
||||||
|
return shaderId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a shader by ID.
|
||||||
|
* 按ID获取着色器。
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `shaderId` - Shader ID to look up. | 要查找的着色器ID。
|
||||||
|
*
|
||||||
|
* # Returns | 返回
|
||||||
|
* Shader instance or undefined if not found. | 着色器实例,未找到则返回 undefined。
|
||||||
|
*/
|
||||||
|
getShader(shaderId: number): Shader | undefined {
|
||||||
|
return this.shaders.get(shaderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a shader by name.
|
||||||
|
* 按名称获取着色器。
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `name` - Shader name to look up. | 要查找的着色器名称。
|
||||||
|
*
|
||||||
|
* # Returns | 返回
|
||||||
|
* Shader instance or undefined if not found. | 着色器实例,未找到则返回 undefined。
|
||||||
|
*/
|
||||||
|
getShaderByName(name: string): Shader | undefined {
|
||||||
|
const id = this.shaderNameToId.get(name);
|
||||||
|
return id !== undefined ? this.shaders.get(id) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a shader exists.
|
||||||
|
* 检查着色器是否存在。
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `shaderId` - Shader ID to check. | 要检查的着色器ID。
|
||||||
|
*/
|
||||||
|
hasShader(shaderId: number): boolean {
|
||||||
|
return this.shaders.has(shaderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a shader.
|
||||||
|
* 移除着色器。
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `shaderId` - Shader ID to remove. | 要移除的着色器ID。
|
||||||
|
*
|
||||||
|
* # Returns | 返回
|
||||||
|
* True if shader was removed, false if not found or is built-in. | 移除成功返回 true,未找到或是内置着色器返回 false。
|
||||||
|
*/
|
||||||
|
removeShader(shaderId: number): boolean {
|
||||||
|
if (shaderId < 100) {
|
||||||
|
logger.warn('Cannot remove built-in shader:', shaderId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shader = this.shaders.get(shaderId);
|
||||||
|
if (shader) {
|
||||||
|
this.shaderNameToId.delete(shader.name);
|
||||||
|
this.shaders.delete(shaderId);
|
||||||
|
|
||||||
|
if (this.engineBridge) {
|
||||||
|
this.engineBridge.removeShader(shaderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Path-based Shader Loading =============
|
||||||
|
// ============= 基于路径的着色器加载 =============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get shader ID by file path.
|
||||||
|
* 通过文件路径获取着色器ID。
|
||||||
|
*
|
||||||
|
* Returns 0 (default shader) if not loaded.
|
||||||
|
* 如果未加载则返回0(默认着色器)。
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `path` - Shader file path (.shader). | 着色器文件路径(.shader)。
|
||||||
|
*
|
||||||
|
* # Returns | 返回
|
||||||
|
* Shader ID or 0 if not found. | 着色器ID,未找到则返回0。
|
||||||
|
*/
|
||||||
|
getShaderIdByPath(path: string): number {
|
||||||
|
if (!path) return 0;
|
||||||
|
return this.shaderPathToId.get(path) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a shader is loaded from a path.
|
||||||
|
* 检查着色器是否已从路径加载。
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `path` - Shader file path to check. | 要检查的着色器文件路径。
|
||||||
|
*/
|
||||||
|
hasShaderByPath(path: string): boolean {
|
||||||
|
return this.shaderPathToId.has(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a shader from a .shader file path.
|
||||||
|
* 从 .shader 文件路径加载着色器。
|
||||||
|
*
|
||||||
|
* Uses asset-system for file loading and caches the result.
|
||||||
|
* 使用 asset-system 进行文件加载并缓存结果。
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `path` - Shader file path. | 着色器文件路径。
|
||||||
|
*
|
||||||
|
* # Returns | 返回
|
||||||
|
* Shader ID (0 if load failed). | 着色器ID(加载失败返回0)。
|
||||||
|
*/
|
||||||
|
async loadShaderFromPath(path: string): Promise<number> {
|
||||||
|
// Return cached ID if already loaded.
|
||||||
|
// 如果已加载则返回缓存的ID。
|
||||||
|
const existingId = this.shaderPathToId.get(path);
|
||||||
|
if (existingId !== undefined) {
|
||||||
|
return existingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return pending promise if already loading.
|
||||||
|
// 如果正在加载则返回等待中的 Promise。
|
||||||
|
const pendingLoad = this.pendingShaderLoads.get(path);
|
||||||
|
if (pendingLoad) {
|
||||||
|
return pendingLoad;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create loading promise.
|
||||||
|
// 创建加载 Promise。
|
||||||
|
const loadPromise = this.doLoadShaderFromPath(path);
|
||||||
|
this.pendingShaderLoads.set(path, loadPromise);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const shaderId = await loadPromise;
|
||||||
|
return shaderId;
|
||||||
|
} finally {
|
||||||
|
this.pendingShaderLoads.delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal method to load shader from path.
|
||||||
|
* 内部方法,从路径加载着色器。
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `path` - Shader file path. | 着色器文件路径。
|
||||||
|
*
|
||||||
|
* # Returns | 返回
|
||||||
|
* Shader ID, or 0 if load failed. | 着色器ID,加载失败返回0。
|
||||||
|
*/
|
||||||
|
private async doLoadShaderFromPath(path: string): Promise<number> {
|
||||||
|
if (!this.assetManager) {
|
||||||
|
logger.warn('No asset manager set, cannot load shader from path:', path);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use asset-system to load shader file.
|
||||||
|
// 使用 asset-system 加载着色器文件。
|
||||||
|
const result = await this.assetManager.loadAssetByPath<IShaderAssetData>(path);
|
||||||
|
|
||||||
|
// Get shader from asset data.
|
||||||
|
// 从资产数据获取着色器。
|
||||||
|
const shader = result.asset.shader;
|
||||||
|
if (!shader) {
|
||||||
|
logger.error('Shader asset is null for path:', path);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the shader.
|
||||||
|
// 注册着色器。
|
||||||
|
const shaderId = await this.registerShader(shader);
|
||||||
|
|
||||||
|
// Cache path -> ID mapping.
|
||||||
|
// 缓存路径到ID的映射。
|
||||||
|
this.shaderPathToId.set(path, shaderId);
|
||||||
|
|
||||||
|
return shaderId;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to load shader from path:', path, error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unload a shader loaded from a path.
|
||||||
|
* 卸载从路径加载的着色器。
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `path` - Shader file path to unload. | 要卸载的着色器文件路径。
|
||||||
|
*
|
||||||
|
* # Returns | 返回
|
||||||
|
* True if unloaded successfully. | 卸载成功返回 true。
|
||||||
|
*/
|
||||||
|
unloadShaderByPath(path: string): boolean {
|
||||||
|
const shaderId = this.shaderPathToId.get(path);
|
||||||
|
if (shaderId === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.shaderPathToId.delete(path);
|
||||||
|
return this.removeShader(shaderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Material Management =============
|
||||||
|
// ============= 材质管理 =============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a material.
|
||||||
|
* 注册材质。
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `material` - Material instance to register. | 要注册的材质实例。
|
||||||
|
*
|
||||||
|
* # Returns | 返回
|
||||||
|
* Material ID for referencing this material. | 用于引用此材质的ID。
|
||||||
|
*/
|
||||||
|
registerMaterial(material: Material): number {
|
||||||
|
const materialId = this.nextMaterialId++;
|
||||||
|
material.id = materialId;
|
||||||
|
|
||||||
|
// Create on GPU if engine bridge is available
|
||||||
|
if (this.engineBridge) {
|
||||||
|
this.engineBridge.createMaterialWithId(
|
||||||
|
materialId,
|
||||||
|
material.name,
|
||||||
|
material.shaderId,
|
||||||
|
material.blendMode
|
||||||
|
);
|
||||||
|
this.syncMaterialUniforms(material);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.materials.set(materialId, material);
|
||||||
|
this.materialNameToId.set(material.name, materialId);
|
||||||
|
|
||||||
|
return materialId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a material by ID.
|
||||||
|
* 按ID获取材质。
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `materialId` - Material ID to look up. | 要查找的材质ID。
|
||||||
|
*
|
||||||
|
* # Returns | 返回
|
||||||
|
* Material instance or undefined if not found. | 材质实例,未找到则返回 undefined。
|
||||||
|
*/
|
||||||
|
getMaterial(materialId: number): Material | undefined {
|
||||||
|
return this.materials.get(materialId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a material by name.
|
||||||
|
* 按名称获取材质。
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `name` - Material name to look up. | 要查找的材质名称。
|
||||||
|
*
|
||||||
|
* # Returns | 返回
|
||||||
|
* Material instance or undefined if not found. | 材质实例,未找到则返回 undefined。
|
||||||
|
*/
|
||||||
|
getMaterialByName(name: string): Material | undefined {
|
||||||
|
const id = this.materialNameToId.get(name);
|
||||||
|
return id !== undefined ? this.materials.get(id) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a material exists.
|
||||||
|
* 检查材质是否存在。
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `materialId` - Material ID to check. | 要检查的材质ID。
|
||||||
|
*/
|
||||||
|
hasMaterial(materialId: number): boolean {
|
||||||
|
return this.materials.has(materialId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a material.
|
||||||
|
* 移除材质。
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `materialId` - Material ID to remove. | 要移除的材质ID。
|
||||||
|
*
|
||||||
|
* # Returns | 返回
|
||||||
|
* True if material was removed, false if not found or is built-in. | 移除成功返回 true,未找到或是内置材质返回 false。
|
||||||
|
*/
|
||||||
|
removeMaterial(materialId: number): boolean {
|
||||||
|
if (materialId < 100) {
|
||||||
|
logger.warn('Cannot remove built-in material:', materialId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const material = this.materials.get(materialId);
|
||||||
|
if (material) {
|
||||||
|
this.materialNameToId.delete(material.name);
|
||||||
|
this.materials.delete(materialId);
|
||||||
|
|
||||||
|
if (this.engineBridge) {
|
||||||
|
this.engineBridge.removeMaterial(materialId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync material uniforms to GPU.
|
||||||
|
* 同步材质 uniform 到GPU。
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `material` - Material to sync. | 要同步的材质。
|
||||||
|
*/
|
||||||
|
syncMaterialUniforms(material: Material): void {
|
||||||
|
if (!this.engineBridge || material.id < 0) return;
|
||||||
|
|
||||||
|
for (const [name, uniform] of material.getUniforms()) {
|
||||||
|
switch (uniform.type) {
|
||||||
|
case UniformType.Float:
|
||||||
|
this.engineBridge.setMaterialFloat(material.id, name, uniform.value as number);
|
||||||
|
break;
|
||||||
|
case UniformType.Vec2: {
|
||||||
|
const v2 = uniform.value as number[];
|
||||||
|
this.engineBridge.setMaterialVec2(material.id, name, v2[0], v2[1]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case UniformType.Vec3: {
|
||||||
|
const v3 = uniform.value as number[];
|
||||||
|
this.engineBridge.setMaterialVec3(material.id, name, v3[0], v3[1], v3[2]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case UniformType.Vec4: {
|
||||||
|
const v4 = uniform.value as number[];
|
||||||
|
this.engineBridge.setMaterialVec4(material.id, name, v4[0], v4[1], v4[2], v4[3]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case UniformType.Color: {
|
||||||
|
const c = uniform.value as number[];
|
||||||
|
this.engineBridge.setMaterialColor(material.id, name, c[0], c[1], c[2], c[3]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
material.markClean();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update all dirty materials.
|
||||||
|
* 更新所有脏材质。
|
||||||
|
*
|
||||||
|
* Syncs all materials that have been modified since last sync.
|
||||||
|
* 同步所有自上次同步以来被修改过的材质。
|
||||||
|
*/
|
||||||
|
syncDirtyMaterials(): void {
|
||||||
|
for (const material of this.materials.values()) {
|
||||||
|
if (material.dirty) {
|
||||||
|
this.syncMaterialUniforms(material);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Path-based Material Loading =============
|
||||||
|
// ============= 基于路径的材质加载 =============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get material ID by file path.
|
||||||
|
* 通过文件路径获取材质ID。
|
||||||
|
*
|
||||||
|
* Returns 0 (default material) if not loaded.
|
||||||
|
* 如果未加载则返回0(默认材质)。
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `path` - Material file path (.mat). | 材质文件路径(.mat)。
|
||||||
|
*
|
||||||
|
* # Returns | 返回
|
||||||
|
* Material ID or 0 if not found. | 材质ID,未找到则返回0。
|
||||||
|
*/
|
||||||
|
getMaterialIdByPath(path: string): number {
|
||||||
|
if (!path) return 0;
|
||||||
|
return this.materialPathToId.get(path) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a material is loaded from a path.
|
||||||
|
* 检查材质是否已从路径加载。
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `path` - Material file path to check. | 要检查的材质文件路径。
|
||||||
|
*/
|
||||||
|
hasMaterialByPath(path: string): boolean {
|
||||||
|
return this.materialPathToId.has(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a material from a .mat file path.
|
||||||
|
* 从 .mat 文件路径加载材质。
|
||||||
|
*
|
||||||
|
* Uses asset-system for file loading and caches the result.
|
||||||
|
* 使用 asset-system 进行文件加载并缓存结果。
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `path` - Material file path. | 材质文件路径。
|
||||||
|
*
|
||||||
|
* # Returns | 返回
|
||||||
|
* Material ID (0 if load failed). | 材质ID(加载失败返回0)。
|
||||||
|
*/
|
||||||
|
async loadMaterialFromPath(path: string): Promise<number> {
|
||||||
|
// Return cached ID if already loaded
|
||||||
|
const existingId = this.materialPathToId.get(path);
|
||||||
|
if (existingId !== undefined) {
|
||||||
|
return existingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return pending promise if already loading
|
||||||
|
const pendingLoad = this.pendingMaterialLoads.get(path);
|
||||||
|
if (pendingLoad) {
|
||||||
|
return pendingLoad;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create loading promise
|
||||||
|
const loadPromise = this.doLoadMaterialFromPath(path);
|
||||||
|
this.pendingMaterialLoads.set(path, loadPromise);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const materialId = await loadPromise;
|
||||||
|
return materialId;
|
||||||
|
} finally {
|
||||||
|
this.pendingMaterialLoads.delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal method to load material from path.
|
||||||
|
* 内部方法,从路径加载材质。
|
||||||
|
*
|
||||||
|
* @param path - Material file path. | 材质文件路径。
|
||||||
|
* @returns Material ID, or 0 if load failed. | 材质ID,加载失败返回0。
|
||||||
|
*/
|
||||||
|
private async doLoadMaterialFromPath(path: string): Promise<number> {
|
||||||
|
if (!this.assetManager) {
|
||||||
|
logger.warn('No asset manager set, cannot load material from path:', path);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use asset-system to load material file.
|
||||||
|
// 使用 asset-system 加载材质文件。
|
||||||
|
const result = await this.assetManager.loadAssetByPath<IMaterialAssetData>(path);
|
||||||
|
|
||||||
|
// Get material from asset data.
|
||||||
|
// 从资产数据获取材质。
|
||||||
|
const material = result.asset.material;
|
||||||
|
if (!material) {
|
||||||
|
logger.error('Material asset is null for path:', path);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the material.
|
||||||
|
// 注册材质。
|
||||||
|
const materialId = this.registerMaterial(material);
|
||||||
|
|
||||||
|
// Cache path -> ID mapping.
|
||||||
|
// 缓存路径到ID的映射。
|
||||||
|
this.materialPathToId.set(path, materialId);
|
||||||
|
|
||||||
|
return materialId;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to load material from path:', path, error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preload multiple materials from paths.
|
||||||
|
* 从路径预加载多个材质。
|
||||||
|
*
|
||||||
|
* Loads all materials in parallel for better performance.
|
||||||
|
* 并行加载所有材质以获得更好的性能。
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `paths` - Array of material file paths. | 材质文件路径数组。
|
||||||
|
*
|
||||||
|
* # Returns | 返回
|
||||||
|
* Map of path to material ID. | 路径到材质ID的映射。
|
||||||
|
*/
|
||||||
|
async preloadMaterials(paths: string[]): Promise<Map<string, number>> {
|
||||||
|
const results = new Map<string, number>();
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
paths.map(async (path) => {
|
||||||
|
const id = await this.loadMaterialFromPath(path);
|
||||||
|
results.set(path, id);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unload a material loaded from a path.
|
||||||
|
* 卸载从路径加载的材质。
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `path` - Material file path to unload. | 要卸载的材质文件路径。
|
||||||
|
*
|
||||||
|
* # Returns | 返回
|
||||||
|
* True if unloaded successfully. | 卸载成功返回 true。
|
||||||
|
*/
|
||||||
|
unloadMaterialByPath(path: string): boolean {
|
||||||
|
const materialId = this.materialPathToId.get(path);
|
||||||
|
if (materialId === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.materialPathToId.delete(path);
|
||||||
|
return this.removeMaterial(materialId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Convenience Methods =============
|
||||||
|
// ============= 便捷方法 =============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a sprite material with optional tint.
|
||||||
|
* 创建带有可选着色的精灵材质。
|
||||||
|
*
|
||||||
|
* # Arguments | 参数
|
||||||
|
* * `name` - Material name. | 材质名称。
|
||||||
|
* * `tintR` - Red tint (0-1). | 红色着色(0-1)。
|
||||||
|
* * `tintG` - Green tint (0-1). | 绿色着色(0-1)。
|
||||||
|
* * `tintB` - Blue tint (0-1). | 蓝色着色(0-1)。
|
||||||
|
*
|
||||||
|
* # Returns | 返回
|
||||||
|
* New Material instance. | 新的材质实例。
|
||||||
|
*/
|
||||||
|
createSpriteMaterial(name: string, tintR: number = 1, tintG: number = 1, tintB: number = 1): Material {
|
||||||
|
const material = new Material(name, BuiltInShaders.DefaultSprite);
|
||||||
|
material.setColor('u_tint', tintR, tintG, tintB, 1.0);
|
||||||
|
return material;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all shader IDs.
|
||||||
|
* 获取所有着色器ID。
|
||||||
|
*
|
||||||
|
* # Returns | 返回
|
||||||
|
* Array of all registered shader IDs. | 所有已注册着色器ID的数组。
|
||||||
|
*/
|
||||||
|
getShaderIds(): number[] {
|
||||||
|
return Array.from(this.shaders.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all material IDs.
|
||||||
|
* 获取所有材质ID。
|
||||||
|
*
|
||||||
|
* # Returns | 返回
|
||||||
|
* Array of all registered material IDs. | 所有已注册材质ID的数组。
|
||||||
|
*/
|
||||||
|
getMaterialIds(): number[] {
|
||||||
|
return Array.from(this.materials.keys());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance.
|
||||||
|
// 单例实例。
|
||||||
|
let materialManagerInstance: MaterialManager | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the global MaterialManager instance.
|
||||||
|
* 获取全局 MaterialManager 实例。
|
||||||
|
*
|
||||||
|
* Creates a new instance if one doesn't exist.
|
||||||
|
* 如果实例不存在则创建新实例。
|
||||||
|
*
|
||||||
|
* # Returns | 返回
|
||||||
|
* The global MaterialManager instance. | 全局 MaterialManager 实例。
|
||||||
|
*/
|
||||||
|
export function getMaterialManager(): MaterialManager {
|
||||||
|
if (!materialManagerInstance) {
|
||||||
|
materialManagerInstance = new MaterialManager();
|
||||||
|
}
|
||||||
|
return materialManagerInstance;
|
||||||
|
}
|
||||||
96
packages/material-system/src/MaterialSystemPlugin.ts
Normal file
96
packages/material-system/src/MaterialSystemPlugin.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* MaterialSystemPlugin for ES Engine.
|
||||||
|
* ES引擎的材质系统插件。
|
||||||
|
*
|
||||||
|
* 注意:材质系统不注册独立组件,材质作为渲染组件(如 SpriteComponent)的属性使用
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MaterialManager, getMaterialManager } from './MaterialManager';
|
||||||
|
import { createLogger } from '@esengine/ecs-framework';
|
||||||
|
import type { IPlugin, ModuleManifest, IRuntimeModule } from '@esengine/engine-core';
|
||||||
|
|
||||||
|
/** Logger instance for MaterialRuntimeModule. | MaterialRuntimeModule的日志实例。 */
|
||||||
|
const logger = createLogger('MaterialRuntimeModule');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime module interface for Material system.
|
||||||
|
* 材质系统的运行时模块接口。
|
||||||
|
*/
|
||||||
|
export interface IMaterialRuntimeModule {
|
||||||
|
onInitialize?(): Promise<void>;
|
||||||
|
onDestroy?(): void;
|
||||||
|
getMaterialManager(): MaterialManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime module that provides material and shader functionality.
|
||||||
|
* 提供材质和着色器功能的运行时模块。
|
||||||
|
*
|
||||||
|
* 该模块提供:
|
||||||
|
* - MaterialManager: 材质资产管理
|
||||||
|
* - 材质文件加载和缓存
|
||||||
|
* - 与 Rust 引擎的材质/着色器桥接
|
||||||
|
*/
|
||||||
|
export class MaterialRuntimeModule implements IMaterialRuntimeModule {
|
||||||
|
private materialManager: MaterialManager;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.materialManager = getMaterialManager();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the material system.
|
||||||
|
* 初始化材质系统。
|
||||||
|
*/
|
||||||
|
async onInitialize(): Promise<void> {
|
||||||
|
logger.info('Initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup the material system.
|
||||||
|
* 清理材质系统。
|
||||||
|
*/
|
||||||
|
onDestroy(): void {
|
||||||
|
logger.info('Destroyed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the material manager.
|
||||||
|
* 获取材质管理器。
|
||||||
|
*/
|
||||||
|
getMaterialManager(): MaterialManager {
|
||||||
|
return this.materialManager;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const materialRuntimeModule = new MaterialRuntimeModule();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin manifest for Material System.
|
||||||
|
* 材质系统的插件清单。
|
||||||
|
*/
|
||||||
|
const manifest: ModuleManifest = {
|
||||||
|
id: 'material-system',
|
||||||
|
name: '@esengine/material-system',
|
||||||
|
displayName: 'Material System',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: '材质和着色器系统',
|
||||||
|
category: 'Rendering',
|
||||||
|
icon: 'Palette',
|
||||||
|
isCore: true,
|
||||||
|
defaultEnabled: true,
|
||||||
|
isEngineModule: true,
|
||||||
|
dependencies: ['core', 'asset-system'],
|
||||||
|
exports: { other: ['Material', 'Shader', 'MaterialManager'] },
|
||||||
|
requiresWasm: false
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material System Plugin.
|
||||||
|
* 材质系统插件。
|
||||||
|
*/
|
||||||
|
export const MaterialSystemPlugin: IPlugin = {
|
||||||
|
manifest,
|
||||||
|
runtimeModule: materialRuntimeModule as IRuntimeModule
|
||||||
|
};
|
||||||
311
packages/material-system/src/Shader.ts
Normal file
311
packages/material-system/src/Shader.ts
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
/**
|
||||||
|
* Shader class for managing GLSL shaders.
|
||||||
|
* 用于管理GLSL着色器的类。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ShaderDefinition, UniformValue, UniformType } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader class that holds vertex and fragment shader source.
|
||||||
|
* 持有顶点和片段着色器源代码的着色器类。
|
||||||
|
*/
|
||||||
|
export class Shader {
|
||||||
|
/** Shader ID assigned by the engine | 引擎分配的着色器ID */
|
||||||
|
private _id: number = -1;
|
||||||
|
|
||||||
|
/** Shader name for reference | 着色器名称用于引用 */
|
||||||
|
public name: string;
|
||||||
|
|
||||||
|
/** Vertex shader GLSL source | 顶点着色器GLSL源代码 */
|
||||||
|
public vertexSource: string;
|
||||||
|
|
||||||
|
/** Fragment shader GLSL source | 片段着色器GLSL源代码 */
|
||||||
|
public fragmentSource: string;
|
||||||
|
|
||||||
|
/** Shader uniforms with default values | 着色器uniform及其默认值 */
|
||||||
|
private uniforms: Map<string, UniformValue> = new Map();
|
||||||
|
|
||||||
|
/** Whether the shader has been compiled | 着色器是否已编译 */
|
||||||
|
private _compiled: boolean = false;
|
||||||
|
|
||||||
|
constructor(name: string, vertexSource: string, fragmentSource: string) {
|
||||||
|
this.name = name;
|
||||||
|
this.vertexSource = vertexSource;
|
||||||
|
this.fragmentSource = fragmentSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the shader ID | 获取着色器ID */
|
||||||
|
get id(): number {
|
||||||
|
return this._id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set the shader ID (called by ShaderManager) | 设置着色器ID(由ShaderManager调用) */
|
||||||
|
set id(value: number) {
|
||||||
|
this._id = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if shader is compiled | 检查着色器是否已编译 */
|
||||||
|
get compiled(): boolean {
|
||||||
|
return this._compiled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark shader as compiled | 标记着色器为已编译 */
|
||||||
|
markCompiled(): void {
|
||||||
|
this._compiled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a uniform with default value.
|
||||||
|
* 定义带有默认值的uniform。
|
||||||
|
*/
|
||||||
|
defineUniform(name: string, type: UniformType, defaultValue: number | number[] | string): this {
|
||||||
|
this.uniforms.set(name, { type, value: defaultValue });
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get uniform definition.
|
||||||
|
* 获取uniform定义。
|
||||||
|
*/
|
||||||
|
getUniform(name: string): UniformValue | undefined {
|
||||||
|
return this.uniforms.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all uniform definitions.
|
||||||
|
* 获取所有uniform定义。
|
||||||
|
*/
|
||||||
|
getUniforms(): Map<string, UniformValue> {
|
||||||
|
return this.uniforms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export to shader definition.
|
||||||
|
* 导出为着色器定义。
|
||||||
|
*/
|
||||||
|
toDefinition(): ShaderDefinition {
|
||||||
|
const uniformsObj: Record<string, UniformValue> = {};
|
||||||
|
for (const [key, value] of this.uniforms) {
|
||||||
|
uniformsObj[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
vertexSource: this.vertexSource,
|
||||||
|
fragmentSource: this.fragmentSource,
|
||||||
|
uniforms: Object.keys(uniformsObj).length > 0 ? uniformsObj : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import from shader definition.
|
||||||
|
* 从着色器定义导入。
|
||||||
|
*/
|
||||||
|
static fromDefinition(def: ShaderDefinition): Shader {
|
||||||
|
const shader = new Shader(def.name, def.vertexSource, def.fragmentSource);
|
||||||
|
|
||||||
|
if (def.uniforms) {
|
||||||
|
for (const [key, value] of Object.entries(def.uniforms)) {
|
||||||
|
shader.uniforms.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return shader;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============= Built-in Shaders =============
|
||||||
|
// ============= 内置着色器 =============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default sprite vertex shader source.
|
||||||
|
* 默认精灵顶点着色器源代码。
|
||||||
|
*/
|
||||||
|
export const DEFAULT_VERTEX_SHADER = `#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
// Vertex attributes | 顶点属性
|
||||||
|
layout(location = 0) in vec2 a_position;
|
||||||
|
layout(location = 1) in vec2 a_texCoord;
|
||||||
|
layout(location = 2) in vec4 a_color;
|
||||||
|
|
||||||
|
// Uniforms | 统一变量
|
||||||
|
uniform mat3 u_projection;
|
||||||
|
|
||||||
|
// Outputs to fragment shader | 输出到片段着色器
|
||||||
|
out vec2 v_texCoord;
|
||||||
|
out vec4 v_color;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// Apply projection matrix | 应用投影矩阵
|
||||||
|
vec3 pos = u_projection * vec3(a_position, 1.0);
|
||||||
|
gl_Position = vec4(pos.xy, 0.0, 1.0);
|
||||||
|
|
||||||
|
// Pass through to fragment shader | 传递到片段着色器
|
||||||
|
v_texCoord = a_texCoord;
|
||||||
|
v_color = a_color;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default sprite fragment shader source.
|
||||||
|
* 默认精灵片段着色器源代码。
|
||||||
|
*/
|
||||||
|
export const DEFAULT_FRAGMENT_SHADER = `#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
// Inputs from vertex shader | 来自顶点着色器的输入
|
||||||
|
in vec2 v_texCoord;
|
||||||
|
in vec4 v_color;
|
||||||
|
|
||||||
|
// Texture sampler | 纹理采样器
|
||||||
|
uniform sampler2D u_texture;
|
||||||
|
|
||||||
|
// Output color | 输出颜色
|
||||||
|
out vec4 fragColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// Sample texture and multiply by vertex color | 采样纹理并乘以顶点颜色
|
||||||
|
vec4 texColor = texture(u_texture, v_texCoord);
|
||||||
|
fragColor = texColor * v_color;
|
||||||
|
|
||||||
|
// Discard fully transparent pixels | 丢弃完全透明的像素
|
||||||
|
if (fragColor.a < 0.01) {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grayscale fragment shader for desaturation effect.
|
||||||
|
* 灰度片段着色器用于去饱和效果。
|
||||||
|
*/
|
||||||
|
export const GRAYSCALE_FRAGMENT_SHADER = `#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
in vec2 v_texCoord;
|
||||||
|
in vec4 v_color;
|
||||||
|
|
||||||
|
uniform sampler2D u_texture;
|
||||||
|
uniform float u_grayscale; // 0.0 = full color, 1.0 = full grayscale
|
||||||
|
|
||||||
|
out vec4 fragColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 texColor = texture(u_texture, v_texCoord);
|
||||||
|
vec4 color = texColor * v_color;
|
||||||
|
|
||||||
|
float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
|
||||||
|
vec3 grayscaleColor = vec3(gray);
|
||||||
|
|
||||||
|
fragColor = vec4(mix(color.rgb, grayscaleColor, u_grayscale), color.a);
|
||||||
|
|
||||||
|
if (fragColor.a < 0.01) {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Color tint fragment shader.
|
||||||
|
* 颜色着色片段着色器。
|
||||||
|
*/
|
||||||
|
export const TINT_FRAGMENT_SHADER = `#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
in vec2 v_texCoord;
|
||||||
|
in vec4 v_color;
|
||||||
|
|
||||||
|
uniform sampler2D u_texture;
|
||||||
|
uniform vec4 u_tintColor; // Tint color to apply
|
||||||
|
|
||||||
|
out vec4 fragColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 texColor = texture(u_texture, v_texCoord);
|
||||||
|
vec4 color = texColor * v_color;
|
||||||
|
|
||||||
|
// Apply tint by multiplying RGB and keeping alpha
|
||||||
|
fragColor = vec4(color.rgb * u_tintColor.rgb, color.a * u_tintColor.a);
|
||||||
|
|
||||||
|
if (fragColor.a < 0.01) {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flash/hit effect fragment shader.
|
||||||
|
* 闪白/受击效果片段着色器。
|
||||||
|
*/
|
||||||
|
export const FLASH_FRAGMENT_SHADER = `#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
in vec2 v_texCoord;
|
||||||
|
in vec4 v_color;
|
||||||
|
|
||||||
|
uniform sampler2D u_texture;
|
||||||
|
uniform vec4 u_flashColor; // Flash color
|
||||||
|
uniform float u_flashAmount; // 0.0 = no flash, 1.0 = full flash
|
||||||
|
|
||||||
|
out vec4 fragColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 texColor = texture(u_texture, v_texCoord);
|
||||||
|
vec4 color = texColor * v_color;
|
||||||
|
|
||||||
|
// Mix original color with flash color
|
||||||
|
vec3 flashedColor = mix(color.rgb, u_flashColor.rgb, u_flashAmount);
|
||||||
|
fragColor = vec4(flashedColor, color.a);
|
||||||
|
|
||||||
|
if (fragColor.a < 0.01) {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outline fragment shader.
|
||||||
|
* 描边片段着色器。
|
||||||
|
*/
|
||||||
|
export const OUTLINE_FRAGMENT_SHADER = `#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
in vec2 v_texCoord;
|
||||||
|
in vec4 v_color;
|
||||||
|
|
||||||
|
uniform sampler2D u_texture;
|
||||||
|
uniform vec4 u_outlineColor;
|
||||||
|
uniform float u_outlineWidth;
|
||||||
|
uniform vec2 u_texelSize; // 1.0 / textureSize
|
||||||
|
|
||||||
|
out vec4 fragColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 texColor = texture(u_texture, v_texCoord);
|
||||||
|
vec4 color = texColor * v_color;
|
||||||
|
|
||||||
|
// Check if this pixel should be outline
|
||||||
|
if (color.a < 0.1) {
|
||||||
|
// Sample neighbors
|
||||||
|
float a = 0.0;
|
||||||
|
for (float x = -1.0; x <= 1.0; x += 1.0) {
|
||||||
|
for (float y = -1.0; y <= 1.0; y += 1.0) {
|
||||||
|
vec2 offset = vec2(x, y) * u_texelSize * u_outlineWidth;
|
||||||
|
a += texture(u_texture, v_texCoord + offset).a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a > 0.0) {
|
||||||
|
fragColor = u_outlineColor;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fragColor = color;
|
||||||
|
|
||||||
|
if (fragColor.a < 0.01) {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
56
packages/material-system/src/index.ts
Normal file
56
packages/material-system/src/index.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Material System for ES Engine.
|
||||||
|
* ES引擎材质系统。
|
||||||
|
*
|
||||||
|
* This system provides:
|
||||||
|
* 该系统提供:
|
||||||
|
*
|
||||||
|
* - Material: Material definition class with shader and uniform parameters.
|
||||||
|
* 材质定义类,包含着色器和 uniform 参数。
|
||||||
|
* - Shader: Shader definition class with vertex and fragment shader code.
|
||||||
|
* 着色器定义类,包含顶点和片段着色器代码。
|
||||||
|
* - MaterialManager: Material asset manager.
|
||||||
|
* 材质资产管理器。
|
||||||
|
* - MaterialLoader: Asset loader for .mat files.
|
||||||
|
* .mat 文件的资产加载器。
|
||||||
|
*
|
||||||
|
* Note: Materials are not standalone components, but used as properties of
|
||||||
|
* render components (such as SpriteComponent).
|
||||||
|
* 注意:材质不是独立组件,而是作为渲染组件(如 SpriteComponent)的属性使用。
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Types.
|
||||||
|
// 类型。
|
||||||
|
export * from './types';
|
||||||
|
|
||||||
|
// Core classes.
|
||||||
|
// 核心类。
|
||||||
|
export { Material } from './Material';
|
||||||
|
export {
|
||||||
|
Shader,
|
||||||
|
DEFAULT_VERTEX_SHADER,
|
||||||
|
DEFAULT_FRAGMENT_SHADER,
|
||||||
|
GRAYSCALE_FRAGMENT_SHADER,
|
||||||
|
TINT_FRAGMENT_SHADER,
|
||||||
|
FLASH_FRAGMENT_SHADER,
|
||||||
|
OUTLINE_FRAGMENT_SHADER
|
||||||
|
} from './Shader';
|
||||||
|
|
||||||
|
// Manager.
|
||||||
|
// 管理器。
|
||||||
|
export { MaterialManager, getMaterialManager } from './MaterialManager';
|
||||||
|
export type { IEngineBridge } from './MaterialManager';
|
||||||
|
|
||||||
|
// Loaders.
|
||||||
|
// 加载器。
|
||||||
|
export { MaterialLoader } from './loaders/MaterialLoader';
|
||||||
|
export type { IMaterialAssetData } from './loaders/MaterialLoader';
|
||||||
|
export { ShaderLoader } from './loaders/ShaderLoader';
|
||||||
|
export type { IShaderAssetData, ShaderFileFormat } from './loaders/ShaderLoader';
|
||||||
|
|
||||||
|
// Runtime Module.
|
||||||
|
// 运行时模块。
|
||||||
|
export { MaterialRuntimeModule, materialRuntimeModule, MaterialSystemPlugin } from './MaterialSystemPlugin';
|
||||||
|
export type { IMaterialRuntimeModule } from './MaterialSystemPlugin';
|
||||||
64
packages/material-system/src/loaders/MaterialLoader.ts
Normal file
64
packages/material-system/src/loaders/MaterialLoader.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Material asset loader.
|
||||||
|
* 材质资产加载器。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
AssetType,
|
||||||
|
IAssetContent,
|
||||||
|
IAssetParseContext
|
||||||
|
} from '@esengine/asset-system';
|
||||||
|
import type { IAssetLoader, AssetContentType } from '@esengine/asset-system';
|
||||||
|
import { Material } from '../Material';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material asset data structure.
|
||||||
|
* 材质资产数据结构。
|
||||||
|
*/
|
||||||
|
export interface IMaterialAssetData {
|
||||||
|
/** Material instance. | 材质实例。 */
|
||||||
|
material: Material;
|
||||||
|
/** Material definition data. | 材质定义数据。 */
|
||||||
|
definition: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material file loader.
|
||||||
|
* 材质文件加载器。
|
||||||
|
*/
|
||||||
|
export class MaterialLoader implements IAssetLoader<IMaterialAssetData> {
|
||||||
|
readonly supportedType = AssetType.Material;
|
||||||
|
readonly supportedExtensions = ['.mat'];
|
||||||
|
readonly contentType: AssetContentType = 'text';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse material from content.
|
||||||
|
* 从内容解析材质。
|
||||||
|
*/
|
||||||
|
async parse(content: IAssetContent, context: IAssetParseContext): Promise<IMaterialAssetData> {
|
||||||
|
if (!content.text) {
|
||||||
|
throw new Error('Material content is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(content.text);
|
||||||
|
|
||||||
|
// Support wrapper format: { material: {...} }
|
||||||
|
const materialDef = data.material || data;
|
||||||
|
|
||||||
|
// Create material from definition.
|
||||||
|
const material = Material.fromDefinition(materialDef);
|
||||||
|
|
||||||
|
return {
|
||||||
|
material,
|
||||||
|
definition: materialDef
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispose material asset.
|
||||||
|
* 释放材质资产。
|
||||||
|
*/
|
||||||
|
dispose(_asset: IMaterialAssetData): void {
|
||||||
|
// Material cleanup if needed.
|
||||||
|
}
|
||||||
|
}
|
||||||
87
packages/material-system/src/loaders/ShaderLoader.ts
Normal file
87
packages/material-system/src/loaders/ShaderLoader.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* Shader asset loader.
|
||||||
|
* 着色器资产加载器。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
AssetType,
|
||||||
|
IAssetContent,
|
||||||
|
IAssetParseContext
|
||||||
|
} from '@esengine/asset-system';
|
||||||
|
import type { IAssetLoader, AssetContentType } from '@esengine/asset-system';
|
||||||
|
import { Shader } from '../Shader';
|
||||||
|
import type { ShaderDefinition } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader asset data structure.
|
||||||
|
* 着色器资产数据结构。
|
||||||
|
*/
|
||||||
|
export interface IShaderAssetData {
|
||||||
|
/** Shader instance. | 着色器实例。 */
|
||||||
|
shader: Shader;
|
||||||
|
/** Shader definition data. | 着色器定义数据。 */
|
||||||
|
definition: ShaderDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader file format.
|
||||||
|
* 着色器文件格式。
|
||||||
|
*
|
||||||
|
* ```json
|
||||||
|
* {
|
||||||
|
* "version": 1,
|
||||||
|
* "shader": {
|
||||||
|
* "name": "CustomShader",
|
||||||
|
* "vertexSource": "...",
|
||||||
|
* "fragmentSource": "...",
|
||||||
|
* "uniforms": { ... }
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export interface ShaderFileFormat {
|
||||||
|
version: number;
|
||||||
|
shader: ShaderDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader file loader.
|
||||||
|
* 着色器文件加载器。
|
||||||
|
*/
|
||||||
|
export class ShaderLoader implements IAssetLoader<IShaderAssetData> {
|
||||||
|
readonly supportedType = AssetType.Shader;
|
||||||
|
readonly supportedExtensions = ['.shader'];
|
||||||
|
readonly contentType: AssetContentType = 'text';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse shader from content.
|
||||||
|
* 从内容解析着色器。
|
||||||
|
*/
|
||||||
|
async parse(content: IAssetContent, context: IAssetParseContext): Promise<IShaderAssetData> {
|
||||||
|
if (!content.text) {
|
||||||
|
throw new Error('Shader content is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(content.text) as ShaderFileFormat;
|
||||||
|
|
||||||
|
if (!data.shader) {
|
||||||
|
throw new Error('Invalid shader file: missing shader definition');
|
||||||
|
}
|
||||||
|
|
||||||
|
const shaderDef = data.shader;
|
||||||
|
const shader = Shader.fromDefinition(shaderDef);
|
||||||
|
|
||||||
|
return {
|
||||||
|
shader,
|
||||||
|
definition: shaderDef
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispose shader asset.
|
||||||
|
* 释放着色器资产。
|
||||||
|
*/
|
||||||
|
dispose(_asset: IShaderAssetData): void {
|
||||||
|
// Shader cleanup if needed.
|
||||||
|
}
|
||||||
|
}
|
||||||
144
packages/material-system/src/types.ts
Normal file
144
packages/material-system/src/types.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* Material and shader types for ES Engine
|
||||||
|
* ES引擎的材质和着色器类型
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blend modes for material rendering.
|
||||||
|
* 材质渲染的混合模式。
|
||||||
|
*/
|
||||||
|
export enum BlendMode {
|
||||||
|
/** No blending, fully opaque | 无混合,完全不透明 */
|
||||||
|
None = 0,
|
||||||
|
/** Standard alpha blending | 标准透明度混合 */
|
||||||
|
Alpha = 1,
|
||||||
|
/** Additive blending (good for glow effects) | 加法混合(适用于发光效果) */
|
||||||
|
Additive = 2,
|
||||||
|
/** Multiplicative blending (good for shadows) | 乘法混合(适用于阴影) */
|
||||||
|
Multiply = 3,
|
||||||
|
/** Screen blending (opposite of multiply) | 滤色混合(与乘法相反) */
|
||||||
|
Screen = 4,
|
||||||
|
/** Premultiplied alpha | 预乘透明度 */
|
||||||
|
PremultipliedAlpha = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cull modes for material rendering.
|
||||||
|
* 材质渲染的剔除模式。
|
||||||
|
*/
|
||||||
|
export enum CullMode {
|
||||||
|
/** No face culling | 不剔除 */
|
||||||
|
None = 0,
|
||||||
|
/** Cull front faces | 剔除正面 */
|
||||||
|
Front = 1,
|
||||||
|
/** Cull back faces | 剔除背面 */
|
||||||
|
Back = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uniform value types supported by the material system.
|
||||||
|
* 材质系统支持的uniform值类型。
|
||||||
|
*/
|
||||||
|
export enum UniformType {
|
||||||
|
Float = 'float',
|
||||||
|
Vec2 = 'vec2',
|
||||||
|
Vec3 = 'vec3',
|
||||||
|
Vec4 = 'vec4',
|
||||||
|
Color = 'color',
|
||||||
|
Int = 'int',
|
||||||
|
Mat3 = 'mat3',
|
||||||
|
Mat4 = 'mat4',
|
||||||
|
Sampler = 'sampler'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uniform value definition.
|
||||||
|
* Uniform值定义。
|
||||||
|
*/
|
||||||
|
export interface UniformValue {
|
||||||
|
type: UniformType;
|
||||||
|
value: number | number[] | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader definition.
|
||||||
|
* 着色器定义。
|
||||||
|
*/
|
||||||
|
export interface ShaderDefinition {
|
||||||
|
/** Shader name for reference | 着色器名称用于引用 */
|
||||||
|
name: string;
|
||||||
|
/** Vertex shader GLSL source | 顶点着色器GLSL源代码 */
|
||||||
|
vertexSource: string;
|
||||||
|
/** Fragment shader GLSL source | 片段着色器GLSL源代码 */
|
||||||
|
fragmentSource: string;
|
||||||
|
/** Shader uniforms with default values | 着色器uniform及其默认值 */
|
||||||
|
uniforms?: Record<string, UniformValue>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material definition.
|
||||||
|
* 材质定义。
|
||||||
|
*/
|
||||||
|
export interface MaterialDefinition {
|
||||||
|
/** Material name | 材质名称 */
|
||||||
|
name: string;
|
||||||
|
/** Shader ID or name to use | 使用的着色器ID或名称 */
|
||||||
|
shader: number | string;
|
||||||
|
/** Blend mode | 混合模式 */
|
||||||
|
blendMode?: BlendMode;
|
||||||
|
/** Cull mode | 剔除模式 */
|
||||||
|
cullMode?: CullMode;
|
||||||
|
/** Depth test enabled | 是否启用深度测试 */
|
||||||
|
depthTest?: boolean;
|
||||||
|
/** Depth write enabled | 是否启用深度写入 */
|
||||||
|
depthWrite?: boolean;
|
||||||
|
/** Material uniform values | 材质uniform值 */
|
||||||
|
uniforms?: Record<string, UniformValue>;
|
||||||
|
/** Texture references by uniform name | 按uniform名称引用的纹理 */
|
||||||
|
textures?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material asset data for serialization.
|
||||||
|
* 用于序列化的材质资产数据。
|
||||||
|
*/
|
||||||
|
export interface MaterialAssetData {
|
||||||
|
version: number;
|
||||||
|
material: MaterialDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader asset data for serialization.
|
||||||
|
* 用于序列化的着色器资产数据。
|
||||||
|
*/
|
||||||
|
export interface ShaderAssetData {
|
||||||
|
version: number;
|
||||||
|
shader: ShaderDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in shader IDs.
|
||||||
|
* 内置着色器ID。
|
||||||
|
*/
|
||||||
|
export const BuiltInShaders = {
|
||||||
|
DefaultSprite: 0,
|
||||||
|
Grayscale: 1,
|
||||||
|
Tint: 2,
|
||||||
|
Flash: 3,
|
||||||
|
Outline: 4
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in material IDs.
|
||||||
|
* 内置材质ID。
|
||||||
|
*/
|
||||||
|
export const BuiltInMaterials = {
|
||||||
|
/** Default sprite material | 默认精灵材质 */
|
||||||
|
Default: 0,
|
||||||
|
/** Additive blend material | 加法混合材质 */
|
||||||
|
Additive: 1,
|
||||||
|
/** Multiply blend material | 乘法混合材质 */
|
||||||
|
Multiply: 2,
|
||||||
|
/** Unlit/opaque material | 无光照/不透明材质 */
|
||||||
|
Unlit: 3
|
||||||
|
} as const;
|
||||||
12
packages/material-system/tsconfig.build.json
Normal file
12
packages/material-system/tsconfig.build.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": false,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||||
|
}
|
||||||
7
packages/material-system/tsconfig.json
Normal file
7
packages/material-system/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.build.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
7
packages/material-system/tsup.config.ts
Normal file
7
packages/material-system/tsup.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'tsup';
|
||||||
|
import { runtimeOnlyPreset } from '../build-config/src/presets/plugin-tsup';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
...runtimeOnlyPreset(),
|
||||||
|
tsconfig: 'tsconfig.build.json'
|
||||||
|
});
|
||||||
45
packages/shader-editor/package.json
Normal file
45
packages/shader-editor/package.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "@esengine/shader-editor",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Shader editor with code editing, analysis, and preview",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"module": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"build:watch": "tsup --watch",
|
||||||
|
"type-check": "tsc --noEmit",
|
||||||
|
"clean": "rimraf dist"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@esengine/ecs-framework": "workspace:*",
|
||||||
|
"@esengine/engine-core": "workspace:*",
|
||||||
|
"@esengine/editor-core": "workspace:*",
|
||||||
|
"@esengine/material-system": "workspace:*",
|
||||||
|
"@esengine/build-config": "workspace:*",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"lucide-react": "^0.453.0",
|
||||||
|
"rimraf": "^5.0.5",
|
||||||
|
"tsup": "^8.0.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"ecs",
|
||||||
|
"shader",
|
||||||
|
"editor",
|
||||||
|
"glsl"
|
||||||
|
],
|
||||||
|
"author": "yhh",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
449
packages/shader-editor/src/analysis/ShaderAnalyzer.ts
Normal file
449
packages/shader-editor/src/analysis/ShaderAnalyzer.ts
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
/**
|
||||||
|
* Shader Analyzer.
|
||||||
|
* 着色器分析器。
|
||||||
|
*
|
||||||
|
* Parses GLSL shader code and extracts information about uniforms, attributes, varyings, and complexity.
|
||||||
|
* 解析 GLSL 着色器代码并提取 uniforms、attributes、varyings 和复杂度信息。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uniform variable info.
|
||||||
|
* Uniform 变量信息。
|
||||||
|
*/
|
||||||
|
export interface UniformInfo {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
arraySize?: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attribute variable info.
|
||||||
|
* Attribute 变量信息。
|
||||||
|
*/
|
||||||
|
export interface AttributeInfo {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
location?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Varying variable info.
|
||||||
|
* Varying 变量信息。
|
||||||
|
*/
|
||||||
|
export interface VaryingInfo {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
qualifier: 'in' | 'out';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader complexity metrics.
|
||||||
|
* 着色器复杂度指标。
|
||||||
|
*/
|
||||||
|
export interface ShaderComplexity {
|
||||||
|
/** Total instruction count estimate. | 估计的总指令数。 */
|
||||||
|
instructionCount: number;
|
||||||
|
/** Texture sample count. | 纹理采样数。 */
|
||||||
|
textureSamples: number;
|
||||||
|
/** Branch count (if/else). | 分支数(if/else)。 */
|
||||||
|
branches: number;
|
||||||
|
/** Loop count. | 循环数。 */
|
||||||
|
loops: number;
|
||||||
|
/** Math operation count. | 数学运算数。 */
|
||||||
|
mathOps: number;
|
||||||
|
/** Complexity level. | 复杂度等级。 */
|
||||||
|
level: 'low' | 'medium' | 'high' | 'very-high';
|
||||||
|
/** Performance tips. | 性能建议。 */
|
||||||
|
tips: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader analysis result.
|
||||||
|
* 着色器分析结果。
|
||||||
|
*/
|
||||||
|
export interface ShaderAnalysis {
|
||||||
|
/** GLSL version. | GLSL 版本。 */
|
||||||
|
version: string;
|
||||||
|
/** Precision. | 精度。 */
|
||||||
|
precision: string;
|
||||||
|
/** Uniforms. | 统一变量。 */
|
||||||
|
uniforms: UniformInfo[];
|
||||||
|
/** Attributes (vertex shader). | 属性(顶点着色器)。 */
|
||||||
|
attributes: AttributeInfo[];
|
||||||
|
/** Varyings (in/out). | 可变量(输入/输出)。 */
|
||||||
|
varyings: VaryingInfo[];
|
||||||
|
/** Complexity metrics. | 复杂度指标。 */
|
||||||
|
complexity: ShaderComplexity;
|
||||||
|
/** Syntax errors. | 语法错误。 */
|
||||||
|
errors: string[];
|
||||||
|
/** Warnings. | 警告。 */
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GLSL type to size mapping.
|
||||||
|
* GLSL 类型到大小的映射。
|
||||||
|
*/
|
||||||
|
const TYPE_SIZES: Record<string, number> = {
|
||||||
|
'float': 1,
|
||||||
|
'int': 1,
|
||||||
|
'uint': 1,
|
||||||
|
'bool': 1,
|
||||||
|
'vec2': 2,
|
||||||
|
'vec3': 3,
|
||||||
|
'vec4': 4,
|
||||||
|
'ivec2': 2,
|
||||||
|
'ivec3': 3,
|
||||||
|
'ivec4': 4,
|
||||||
|
'uvec2': 2,
|
||||||
|
'uvec3': 3,
|
||||||
|
'uvec4': 4,
|
||||||
|
'bvec2': 2,
|
||||||
|
'bvec3': 3,
|
||||||
|
'bvec4': 4,
|
||||||
|
'mat2': 4,
|
||||||
|
'mat3': 9,
|
||||||
|
'mat4': 16,
|
||||||
|
'sampler2D': 1,
|
||||||
|
'samplerCube': 1,
|
||||||
|
'sampler3D': 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader Analyzer class.
|
||||||
|
* 着色器分析器类。
|
||||||
|
*/
|
||||||
|
export class ShaderAnalyzer {
|
||||||
|
/**
|
||||||
|
* Analyze shader source code.
|
||||||
|
* 分析着色器源代码。
|
||||||
|
*/
|
||||||
|
analyze(source: string, isVertex: boolean = false): ShaderAnalysis {
|
||||||
|
const result: ShaderAnalysis = {
|
||||||
|
version: '',
|
||||||
|
precision: '',
|
||||||
|
uniforms: [],
|
||||||
|
attributes: [],
|
||||||
|
varyings: [],
|
||||||
|
complexity: {
|
||||||
|
instructionCount: 0,
|
||||||
|
textureSamples: 0,
|
||||||
|
branches: 0,
|
||||||
|
loops: 0,
|
||||||
|
mathOps: 0,
|
||||||
|
level: 'low',
|
||||||
|
tips: []
|
||||||
|
},
|
||||||
|
errors: [],
|
||||||
|
warnings: []
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Remove comments for analysis.
|
||||||
|
// 移除注释用于分析。
|
||||||
|
const cleanSource = this.removeComments(source);
|
||||||
|
|
||||||
|
// Parse version.
|
||||||
|
// 解析版本。
|
||||||
|
result.version = this.parseVersion(cleanSource);
|
||||||
|
|
||||||
|
// Parse precision.
|
||||||
|
// 解析精度。
|
||||||
|
result.precision = this.parsePrecision(cleanSource);
|
||||||
|
|
||||||
|
// Parse uniforms.
|
||||||
|
// 解析 uniforms。
|
||||||
|
result.uniforms = this.parseUniforms(cleanSource);
|
||||||
|
|
||||||
|
// Parse attributes (vertex shader only).
|
||||||
|
// 解析属性(仅顶点着色器)。
|
||||||
|
if (isVertex) {
|
||||||
|
result.attributes = this.parseAttributes(cleanSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse varyings (in/out).
|
||||||
|
// 解析可变量(输入/输出)。
|
||||||
|
result.varyings = this.parseVaryings(cleanSource, isVertex);
|
||||||
|
|
||||||
|
// Analyze complexity.
|
||||||
|
// 分析复杂度。
|
||||||
|
result.complexity = this.analyzeComplexity(cleanSource);
|
||||||
|
|
||||||
|
// Check for common issues.
|
||||||
|
// 检查常见问题。
|
||||||
|
this.checkWarnings(cleanSource, result);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
result.errors.push(`Analysis error: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove comments from source.
|
||||||
|
* 从源代码中移除注释。
|
||||||
|
*/
|
||||||
|
private removeComments(source: string): string {
|
||||||
|
// Remove single-line comments.
|
||||||
|
// 移除单行注释。
|
||||||
|
let result = source.replace(/\/\/.*$/gm, '');
|
||||||
|
// Remove multi-line comments.
|
||||||
|
// 移除多行注释。
|
||||||
|
result = result.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse GLSL version.
|
||||||
|
* 解析 GLSL 版本。
|
||||||
|
*/
|
||||||
|
private parseVersion(source: string): string {
|
||||||
|
const match = source.match(/#version\s+(\d+\s*\w*)/);
|
||||||
|
return match ? match[1].trim() : 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse precision qualifier.
|
||||||
|
* 解析精度限定符。
|
||||||
|
*/
|
||||||
|
private parsePrecision(source: string): string {
|
||||||
|
const match = source.match(/precision\s+(lowp|mediump|highp)\s+float/);
|
||||||
|
return match ? match[1] : 'not specified';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse uniform declarations.
|
||||||
|
* 解析 uniform 声明。
|
||||||
|
*/
|
||||||
|
private parseUniforms(source: string): UniformInfo[] {
|
||||||
|
const uniforms: UniformInfo[] = [];
|
||||||
|
// Match: uniform type name; or uniform type name[size];
|
||||||
|
const regex = /uniform\s+(\w+)\s+(\w+)(?:\[(\d+)\])?;/g;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = regex.exec(source)) !== null) {
|
||||||
|
const info: UniformInfo = {
|
||||||
|
name: match[2],
|
||||||
|
type: match[1]
|
||||||
|
};
|
||||||
|
if (match[3]) {
|
||||||
|
info.arraySize = parseInt(match[3], 10);
|
||||||
|
}
|
||||||
|
uniforms.push(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniforms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse attribute declarations.
|
||||||
|
* 解析 attribute 声明。
|
||||||
|
*/
|
||||||
|
private parseAttributes(source: string): AttributeInfo[] {
|
||||||
|
const attributes: AttributeInfo[] = [];
|
||||||
|
|
||||||
|
// GLSL 300 es style: layout(location = n) in type name;
|
||||||
|
const layoutRegex = /layout\s*\(\s*location\s*=\s*(\d+)\s*\)\s*in\s+(\w+)\s+(\w+);/g;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = layoutRegex.exec(source)) !== null) {
|
||||||
|
attributes.push({
|
||||||
|
name: match[3],
|
||||||
|
type: match[2],
|
||||||
|
location: parseInt(match[1], 10)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old style: attribute type name; or in type name;
|
||||||
|
const attrRegex = /(?:attribute|in)\s+(\w+)\s+(\w+);/g;
|
||||||
|
let attrMatch: RegExpExecArray | null;
|
||||||
|
while ((attrMatch = attrRegex.exec(source)) !== null) {
|
||||||
|
// Skip if already added via layout.
|
||||||
|
if (!attributes.find(a => a.name === attrMatch![2])) {
|
||||||
|
attributes.push({
|
||||||
|
name: attrMatch[2],
|
||||||
|
type: attrMatch[1]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse varying declarations (in/out).
|
||||||
|
* 解析可变量声明(输入/输出)。
|
||||||
|
*/
|
||||||
|
private parseVaryings(source: string, isVertex: boolean): VaryingInfo[] {
|
||||||
|
const varyings: VaryingInfo[] = [];
|
||||||
|
|
||||||
|
// Parse 'out' declarations.
|
||||||
|
// 解析 'out' 声明。
|
||||||
|
const outRegex = /(?<!layout\s*\([^)]*\)\s*)out\s+(\w+)\s+(\w+);/g;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = outRegex.exec(source)) !== null) {
|
||||||
|
varyings.push({
|
||||||
|
name: match[2],
|
||||||
|
type: match[1],
|
||||||
|
qualifier: 'out'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse 'in' declarations (skip attributes in vertex shader).
|
||||||
|
// 解析 'in' 声明(跳过顶点着色器中的属性)。
|
||||||
|
if (!isVertex) {
|
||||||
|
const inRegex = /(?<!layout\s*\([^)]*\)\s*)in\s+(\w+)\s+(\w+);/g;
|
||||||
|
while ((match = inRegex.exec(source)) !== null) {
|
||||||
|
varyings.push({
|
||||||
|
name: match[2],
|
||||||
|
type: match[1],
|
||||||
|
qualifier: 'in'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return varyings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze shader complexity.
|
||||||
|
* 分析着色器复杂度。
|
||||||
|
*/
|
||||||
|
private analyzeComplexity(source: string): ShaderComplexity {
|
||||||
|
const complexity: ShaderComplexity = {
|
||||||
|
instructionCount: 0,
|
||||||
|
textureSamples: 0,
|
||||||
|
branches: 0,
|
||||||
|
loops: 0,
|
||||||
|
mathOps: 0,
|
||||||
|
level: 'low',
|
||||||
|
tips: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Count texture samples.
|
||||||
|
// 统计纹理采样。
|
||||||
|
const textureCalls = source.match(/texture\s*\(/g) || [];
|
||||||
|
complexity.textureSamples = textureCalls.length;
|
||||||
|
|
||||||
|
// Count branches.
|
||||||
|
// 统计分支。
|
||||||
|
const ifStatements = source.match(/\bif\s*\(/g) || [];
|
||||||
|
const ternary = source.match(/\?/g) || [];
|
||||||
|
complexity.branches = ifStatements.length + ternary.length;
|
||||||
|
|
||||||
|
// Count loops.
|
||||||
|
// 统计循环。
|
||||||
|
const forLoops = source.match(/\bfor\s*\(/g) || [];
|
||||||
|
const whileLoops = source.match(/\bwhile\s*\(/g) || [];
|
||||||
|
complexity.loops = forLoops.length + whileLoops.length;
|
||||||
|
|
||||||
|
// Count math operations.
|
||||||
|
// 统计数学运算。
|
||||||
|
const mathFuncs = [
|
||||||
|
'sin', 'cos', 'tan', 'asin', 'acos', 'atan',
|
||||||
|
'pow', 'exp', 'log', 'sqrt', 'inversesqrt',
|
||||||
|
'abs', 'floor', 'ceil', 'fract', 'mod',
|
||||||
|
'min', 'max', 'clamp', 'mix', 'step', 'smoothstep',
|
||||||
|
'length', 'distance', 'dot', 'cross', 'normalize', 'reflect', 'refract'
|
||||||
|
];
|
||||||
|
for (const func of mathFuncs) {
|
||||||
|
const matches = source.match(new RegExp(`\\b${func}\\s*\\(`, 'g')) || [];
|
||||||
|
complexity.mathOps += matches.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate instruction count.
|
||||||
|
// 估计指令数。
|
||||||
|
const lines = source.split('\n').filter(l => l.trim() && !l.trim().startsWith('//'));
|
||||||
|
complexity.instructionCount = lines.length +
|
||||||
|
complexity.textureSamples * 4 +
|
||||||
|
complexity.mathOps * 2 +
|
||||||
|
complexity.branches * 2;
|
||||||
|
|
||||||
|
// Determine complexity level.
|
||||||
|
// 确定复杂度等级。
|
||||||
|
if (complexity.instructionCount > 200 || complexity.textureSamples > 8 || complexity.loops > 3) {
|
||||||
|
complexity.level = 'very-high';
|
||||||
|
} else if (complexity.instructionCount > 100 || complexity.textureSamples > 4 || complexity.loops > 1) {
|
||||||
|
complexity.level = 'high';
|
||||||
|
} else if (complexity.instructionCount > 50 || complexity.textureSamples > 2) {
|
||||||
|
complexity.level = 'medium';
|
||||||
|
} else {
|
||||||
|
complexity.level = 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate tips.
|
||||||
|
// 生成建议。
|
||||||
|
if (complexity.textureSamples > 4) {
|
||||||
|
complexity.tips.push('Consider reducing texture samples for better performance.');
|
||||||
|
}
|
||||||
|
if (complexity.loops > 2) {
|
||||||
|
complexity.tips.push('Nested loops can significantly impact performance.');
|
||||||
|
}
|
||||||
|
if (complexity.branches > 5) {
|
||||||
|
complexity.tips.push('Many branches can cause performance issues on some GPUs.');
|
||||||
|
}
|
||||||
|
if (source.includes('discard')) {
|
||||||
|
complexity.tips.push('Using discard can prevent early-z optimization.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return complexity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for common issues and warnings.
|
||||||
|
* 检查常见问题和警告。
|
||||||
|
*/
|
||||||
|
private checkWarnings(source: string, result: ShaderAnalysis): void {
|
||||||
|
// Check for missing precision.
|
||||||
|
// 检查缺少精度。
|
||||||
|
if (result.precision === 'not specified') {
|
||||||
|
result.warnings.push('No precision qualifier specified. Consider adding "precision highp float;"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for unused uniforms.
|
||||||
|
// 检查未使用的 uniforms。
|
||||||
|
for (const uniform of result.uniforms) {
|
||||||
|
const usageRegex = new RegExp(`\\b${uniform.name}\\b`, 'g');
|
||||||
|
const matches = source.match(usageRegex) || [];
|
||||||
|
if (matches.length <= 1) { // Only the declaration.
|
||||||
|
result.warnings.push(`Uniform "${uniform.name}" may be unused.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for expensive operations in loops.
|
||||||
|
// 检查循环中的昂贵操作。
|
||||||
|
if (result.complexity.loops > 0 && result.complexity.textureSamples > 0) {
|
||||||
|
// Simple heuristic: if we have loops and texture samples, warn.
|
||||||
|
result.warnings.push('Texture sampling in loops can be expensive.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get type size in floats.
|
||||||
|
* 获取类型大小(以 float 为单位)。
|
||||||
|
*/
|
||||||
|
getTypeSize(type: string): number {
|
||||||
|
return TYPE_SIZES[type] || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate total uniform buffer size.
|
||||||
|
* 计算 uniform 缓冲区总大小。
|
||||||
|
*/
|
||||||
|
calculateUniformBufferSize(uniforms: UniformInfo[]): number {
|
||||||
|
let size = 0;
|
||||||
|
for (const uniform of uniforms) {
|
||||||
|
const typeSize = this.getTypeSize(uniform.type);
|
||||||
|
const count = uniform.arraySize || 1;
|
||||||
|
size += typeSize * count;
|
||||||
|
}
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance.
|
||||||
|
// 导出单例实例。
|
||||||
|
export const shaderAnalyzer = new ShaderAnalyzer();
|
||||||
396
packages/shader-editor/src/components/ShaderEditorPanel.css
Normal file
396
packages/shader-editor/src/components/ShaderEditorPanel.css
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
/**
|
||||||
|
* Shader Editor Panel Styles.
|
||||||
|
* 着色器编辑器面板样式。
|
||||||
|
*/
|
||||||
|
|
||||||
|
.shader-editor-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg-primary, #1e1e1e);
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-editor-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-editor-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.shader-editor-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #333);
|
||||||
|
background: var(--bg-secondary, #252526);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-editor-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-editor-dirty {
|
||||||
|
color: var(--warning-color, #fbbf24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-editor-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-editor-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid var(--border-color, #444);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--button-bg, #333);
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-editor-btn:hover:not(:disabled) {
|
||||||
|
background: var(--button-hover-bg, #444);
|
||||||
|
border-color: var(--accent-color, #0078d4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-editor-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status bar */
|
||||||
|
.shader-editor-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-editor-status.error {
|
||||||
|
background: var(--error-bg, #3a2020);
|
||||||
|
color: var(--error-color, #f87171);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-editor-status.success {
|
||||||
|
background: var(--success-bg, #1a3a1a);
|
||||||
|
color: var(--success-color, #4ade80);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content */
|
||||||
|
.shader-editor-content {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code section */
|
||||||
|
.shader-editor-code-section {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid var(--border-color, #333);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.shader-editor-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--border-color, #333);
|
||||||
|
background: var(--bg-secondary, #252526);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-editor-tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-editor-tab:hover {
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-editor-tab.active {
|
||||||
|
color: var(--accent-color, #0078d4);
|
||||||
|
border-bottom-color: var(--accent-color, #0078d4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Textarea wrapper */
|
||||||
|
.shader-editor-textarea-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-editor-line-numbers {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 12px 8px;
|
||||||
|
background: var(--bg-tertiary, #1a1a1a);
|
||||||
|
color: var(--text-tertiary, #666);
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: right;
|
||||||
|
user-select: none;
|
||||||
|
border-right: 1px solid var(--border-color, #333);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-editor-line-numbers span {
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-editor-textarea {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-primary, #1e1e1e);
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
tab-size: 4;
|
||||||
|
white-space: pre;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-editor-textarea::placeholder {
|
||||||
|
color: var(--text-tertiary, #555);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Analysis section */
|
||||||
|
.shader-editor-analysis-section {
|
||||||
|
width: 280px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-secondary, #252526);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-editor-section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #333);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-editor-section-header:hover {
|
||||||
|
background: var(--bg-hover, #2a2a2a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-editor-analysis-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Analysis sections */
|
||||||
|
.analysis-section {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-tertiary, #1e1e1e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-section-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Complexity badge */
|
||||||
|
.complexity-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.complexity-badge.low {
|
||||||
|
background: var(--success-bg, #1a3a1a);
|
||||||
|
color: var(--success-color, #4ade80);
|
||||||
|
}
|
||||||
|
|
||||||
|
.complexity-badge.medium {
|
||||||
|
background: var(--info-bg, #1a2a3a);
|
||||||
|
color: var(--info-color, #60a5fa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.complexity-badge.high {
|
||||||
|
background: var(--warning-bg, #3a3a1a);
|
||||||
|
color: var(--warning-color, #fbbf24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.complexity-badge.very-high {
|
||||||
|
background: var(--error-bg, #3a2020);
|
||||||
|
color: var(--error-color, #f87171);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metrics */
|
||||||
|
.analysis-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Analysis list */
|
||||||
|
.analysis-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--bg-primary, #1e1e1e);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-type {
|
||||||
|
color: var(--accent-color, #0078d4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-array {
|
||||||
|
color: var(--text-tertiary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-location {
|
||||||
|
color: var(--text-tertiary, #666);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-qualifier {
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-qualifier.in {
|
||||||
|
background: var(--success-bg, #1a3a1a);
|
||||||
|
color: var(--success-color, #4ade80);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-qualifier.out {
|
||||||
|
background: var(--info-bg, #1a2a3a);
|
||||||
|
color: var(--info-color, #60a5fa);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tips and warnings */
|
||||||
|
.analysis-tips,
|
||||||
|
.analysis-warnings {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-tip,
|
||||||
|
.analysis-warning {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-tip {
|
||||||
|
background: var(--warning-bg, #3a3a1a);
|
||||||
|
color: var(--warning-color, #fbbf24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-warning {
|
||||||
|
background: var(--warning-bg, #3a3a1a);
|
||||||
|
color: var(--warning-color, #fbbf24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-tip svg,
|
||||||
|
.analysis-warning svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info */
|
||||||
|
.analysis-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
430
packages/shader-editor/src/components/ShaderEditorPanel.tsx
Normal file
430
packages/shader-editor/src/components/ShaderEditorPanel.tsx
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
/**
|
||||||
|
* Shader Editor Panel.
|
||||||
|
* 着色器编辑器面板。
|
||||||
|
*
|
||||||
|
* Provides shader code editing, analysis, and preview.
|
||||||
|
* 提供着色器代码编辑、分析和预览功能。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { Core } from '@esengine/ecs-framework';
|
||||||
|
import { MessageHub, IFileSystemService, IFileSystem, ProjectService } from '@esengine/editor-core';
|
||||||
|
import { getMaterialManager, Shader } from '@esengine/material-system';
|
||||||
|
import {
|
||||||
|
Save, RefreshCw, Play, AlertTriangle, CheckCircle,
|
||||||
|
Code, Eye, BarChart3, FileCode, ChevronDown, ChevronRight
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { ShaderAnalyzer, ShaderAnalysis } from '../analysis/ShaderAnalyzer';
|
||||||
|
import { useShaderEditorStore } from '../stores/ShaderEditorStore';
|
||||||
|
import './ShaderEditorPanel.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader Editor Panel Props.
|
||||||
|
* 着色器编辑器面板属性。
|
||||||
|
*/
|
||||||
|
interface ShaderEditorPanelProps {
|
||||||
|
filePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader Editor Panel Component.
|
||||||
|
* 着色器编辑器面板组件。
|
||||||
|
*/
|
||||||
|
export function ShaderEditorPanel({ filePath: propFilePath }: ShaderEditorPanelProps) {
|
||||||
|
const {
|
||||||
|
filePath, shaderData, isDirty,
|
||||||
|
setFilePath, setShaderData, setDirty, reset
|
||||||
|
} = useShaderEditorStore();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<'vertex' | 'fragment'>('fragment');
|
||||||
|
const [showAnalysis, setShowAnalysis] = useState(true);
|
||||||
|
const [showPreview, setShowPreview] = useState(true);
|
||||||
|
const [vertexAnalysis, setVertexAnalysis] = useState<ShaderAnalysis | null>(null);
|
||||||
|
const [fragmentAnalysis, setFragmentAnalysis] = useState<ShaderAnalysis | null>(null);
|
||||||
|
const [compileError, setCompileError] = useState<string | null>(null);
|
||||||
|
const [compileSuccess, setCompileSuccess] = useState(false);
|
||||||
|
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const analyzer = useRef(new ShaderAnalyzer());
|
||||||
|
|
||||||
|
// Load shader file.
|
||||||
|
// 加载着色器文件。
|
||||||
|
useEffect(() => {
|
||||||
|
const pathToLoad = propFilePath || filePath;
|
||||||
|
if (pathToLoad) {
|
||||||
|
loadShaderFile(pathToLoad);
|
||||||
|
}
|
||||||
|
}, [propFilePath]);
|
||||||
|
|
||||||
|
// Subscribe to file open messages.
|
||||||
|
// 订阅文件打开消息。
|
||||||
|
useEffect(() => {
|
||||||
|
const messageHub = Core.services.tryResolve(MessageHub);
|
||||||
|
if (!messageHub) return;
|
||||||
|
|
||||||
|
const unsubscribe = messageHub.subscribe('shader:open', (payload: { filePath: string }) => {
|
||||||
|
loadShaderFile(payload.filePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Analyze shader when source changes.
|
||||||
|
// 当源代码改变时分析着色器。
|
||||||
|
useEffect(() => {
|
||||||
|
if (shaderData) {
|
||||||
|
setVertexAnalysis(analyzer.current.analyze(shaderData.vertex, true));
|
||||||
|
setFragmentAnalysis(analyzer.current.analyze(shaderData.fragment, false));
|
||||||
|
}
|
||||||
|
}, [shaderData?.vertex, shaderData?.fragment]);
|
||||||
|
|
||||||
|
const loadShaderFile = async (path: string) => {
|
||||||
|
const fileSystem = Core.services.tryResolve<IFileSystem>(IFileSystemService);
|
||||||
|
if (!fileSystem) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await fileSystem.readFile(path);
|
||||||
|
const data = JSON.parse(content);
|
||||||
|
setFilePath(path);
|
||||||
|
setShaderData({
|
||||||
|
version: data.version || 1,
|
||||||
|
name: data.shader.name || 'Untitled',
|
||||||
|
vertex: data.shader.vertexSource || '',
|
||||||
|
fragment: data.shader.fragmentSource || ''
|
||||||
|
});
|
||||||
|
setDirty(false);
|
||||||
|
setCompileError(null);
|
||||||
|
setCompileSuccess(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ShaderEditorPanel] Failed to load shader:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!filePath || !shaderData) return;
|
||||||
|
|
||||||
|
const fileSystem = Core.services.tryResolve<IFileSystem>(IFileSystemService);
|
||||||
|
if (!fileSystem) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Save in new wrapper format.
|
||||||
|
// 以新的包装格式保存。
|
||||||
|
const fileData = {
|
||||||
|
version: shaderData.version || 1,
|
||||||
|
shader: {
|
||||||
|
name: shaderData.name,
|
||||||
|
vertexSource: shaderData.vertex,
|
||||||
|
fragmentSource: shaderData.fragment
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const content = JSON.stringify(fileData, null, 2);
|
||||||
|
await fileSystem.writeFile(filePath, content);
|
||||||
|
setDirty(false);
|
||||||
|
|
||||||
|
// Notify.
|
||||||
|
const messageHub = Core.services.tryResolve(MessageHub);
|
||||||
|
if (messageHub) {
|
||||||
|
messageHub.publish('shader:saved', { filePath });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ShaderEditorPanel] Failed to save shader:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCompile = async () => {
|
||||||
|
if (!shaderData) return;
|
||||||
|
|
||||||
|
setCompileError(null);
|
||||||
|
setCompileSuccess(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const materialManager = getMaterialManager();
|
||||||
|
if (!materialManager) {
|
||||||
|
setCompileError('MaterialManager not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temporary shader to test compilation.
|
||||||
|
// 创建临时着色器测试编译。
|
||||||
|
const testShader = new Shader(
|
||||||
|
`test_${Date.now()}`,
|
||||||
|
shaderData.vertex,
|
||||||
|
shaderData.fragment
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to register (which compiles).
|
||||||
|
// 尝试注册(会进行编译)。
|
||||||
|
const shaderId = await materialManager.registerShader(testShader);
|
||||||
|
|
||||||
|
if (shaderId > 0) {
|
||||||
|
setCompileSuccess(true);
|
||||||
|
// Remove test shader.
|
||||||
|
materialManager.removeShader(shaderId);
|
||||||
|
} else {
|
||||||
|
setCompileError('Compilation failed');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setCompileError(error.message || 'Compilation failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSourceChange = (type: 'vertex' | 'fragment', value: string) => {
|
||||||
|
if (!shaderData) return;
|
||||||
|
|
||||||
|
setShaderData({
|
||||||
|
...shaderData,
|
||||||
|
[type]: value
|
||||||
|
});
|
||||||
|
setDirty(true);
|
||||||
|
setCompileSuccess(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentAnalysis = activeTab === 'vertex' ? vertexAnalysis : fragmentAnalysis;
|
||||||
|
|
||||||
|
if (!shaderData) {
|
||||||
|
return (
|
||||||
|
<div className="shader-editor-panel shader-editor-empty">
|
||||||
|
<FileCode size={48} />
|
||||||
|
<p>No shader loaded</p>
|
||||||
|
<p className="shader-editor-hint">Open a .shader file to edit</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shader-editor-panel">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="shader-editor-header">
|
||||||
|
<div className="shader-editor-title">
|
||||||
|
<FileCode size={16} />
|
||||||
|
<span>{shaderData.name}</span>
|
||||||
|
{isDirty && <span className="shader-editor-dirty">*</span>}
|
||||||
|
</div>
|
||||||
|
<div className="shader-editor-actions">
|
||||||
|
<button
|
||||||
|
className="shader-editor-btn"
|
||||||
|
onClick={handleCompile}
|
||||||
|
title="Compile shader"
|
||||||
|
>
|
||||||
|
<Play size={14} />
|
||||||
|
Compile
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="shader-editor-btn"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!isDirty}
|
||||||
|
title="Save shader"
|
||||||
|
>
|
||||||
|
<Save size={14} />
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compile status */}
|
||||||
|
{compileError && (
|
||||||
|
<div className="shader-editor-status error">
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
<span>{compileError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{compileSuccess && (
|
||||||
|
<div className="shader-editor-status success">
|
||||||
|
<CheckCircle size={14} />
|
||||||
|
<span>Compilation successful!</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="shader-editor-content">
|
||||||
|
{/* Code editor */}
|
||||||
|
<div className="shader-editor-code-section">
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="shader-editor-tabs">
|
||||||
|
<button
|
||||||
|
className={`shader-editor-tab ${activeTab === 'vertex' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('vertex')}
|
||||||
|
>
|
||||||
|
<Code size={12} />
|
||||||
|
Vertex
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`shader-editor-tab ${activeTab === 'fragment' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('fragment')}
|
||||||
|
>
|
||||||
|
<Code size={12} />
|
||||||
|
Fragment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor */}
|
||||||
|
<div className="shader-editor-textarea-wrapper">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
className="shader-editor-textarea"
|
||||||
|
value={activeTab === 'vertex' ? shaderData.vertex : shaderData.fragment}
|
||||||
|
onChange={e => handleSourceChange(activeTab, e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={`Enter ${activeTab} shader code...`}
|
||||||
|
/>
|
||||||
|
<div className="shader-editor-line-numbers">
|
||||||
|
{(activeTab === 'vertex' ? shaderData.vertex : shaderData.fragment)
|
||||||
|
.split('\n')
|
||||||
|
.map((_, i) => (
|
||||||
|
<span key={i}>{i + 1}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Analysis panel */}
|
||||||
|
<div className="shader-editor-analysis-section">
|
||||||
|
{/* Analysis header */}
|
||||||
|
<div
|
||||||
|
className="shader-editor-section-header"
|
||||||
|
onClick={() => setShowAnalysis(!showAnalysis)}
|
||||||
|
>
|
||||||
|
{showAnalysis ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
<BarChart3 size={14} />
|
||||||
|
<span>Analysis</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAnalysis && currentAnalysis && (
|
||||||
|
<div className="shader-editor-analysis-content">
|
||||||
|
{/* Complexity */}
|
||||||
|
<div className="analysis-section">
|
||||||
|
<div className="analysis-section-title">Complexity</div>
|
||||||
|
<div className={`complexity-badge ${currentAnalysis.complexity.level}`}>
|
||||||
|
{currentAnalysis.complexity.level.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="analysis-metrics">
|
||||||
|
<div className="metric">
|
||||||
|
<span className="metric-label">Instructions</span>
|
||||||
|
<span className="metric-value">~{currentAnalysis.complexity.instructionCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="metric">
|
||||||
|
<span className="metric-label">Texture Samples</span>
|
||||||
|
<span className="metric-value">{currentAnalysis.complexity.textureSamples}</span>
|
||||||
|
</div>
|
||||||
|
<div className="metric">
|
||||||
|
<span className="metric-label">Branches</span>
|
||||||
|
<span className="metric-value">{currentAnalysis.complexity.branches}</span>
|
||||||
|
</div>
|
||||||
|
<div className="metric">
|
||||||
|
<span className="metric-label">Loops</span>
|
||||||
|
<span className="metric-value">{currentAnalysis.complexity.loops}</span>
|
||||||
|
</div>
|
||||||
|
<div className="metric">
|
||||||
|
<span className="metric-label">Math Ops</span>
|
||||||
|
<span className="metric-value">{currentAnalysis.complexity.mathOps}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Uniforms */}
|
||||||
|
{currentAnalysis.uniforms.length > 0 && (
|
||||||
|
<div className="analysis-section">
|
||||||
|
<div className="analysis-section-title">
|
||||||
|
Uniforms ({currentAnalysis.uniforms.length})
|
||||||
|
</div>
|
||||||
|
<div className="analysis-list">
|
||||||
|
{currentAnalysis.uniforms.map((u, i) => (
|
||||||
|
<div key={i} className="analysis-item">
|
||||||
|
<span className="item-type">{u.type}</span>
|
||||||
|
<span className="item-name">{u.name}</span>
|
||||||
|
{u.arraySize && <span className="item-array">[{u.arraySize}]</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attributes (vertex only) */}
|
||||||
|
{activeTab === 'vertex' && currentAnalysis.attributes.length > 0 && (
|
||||||
|
<div className="analysis-section">
|
||||||
|
<div className="analysis-section-title">
|
||||||
|
Attributes ({currentAnalysis.attributes.length})
|
||||||
|
</div>
|
||||||
|
<div className="analysis-list">
|
||||||
|
{currentAnalysis.attributes.map((a, i) => (
|
||||||
|
<div key={i} className="analysis-item">
|
||||||
|
<span className="item-type">{a.type}</span>
|
||||||
|
<span className="item-name">{a.name}</span>
|
||||||
|
{a.location !== undefined && (
|
||||||
|
<span className="item-location">loc={a.location}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Varyings */}
|
||||||
|
{currentAnalysis.varyings.length > 0 && (
|
||||||
|
<div className="analysis-section">
|
||||||
|
<div className="analysis-section-title">
|
||||||
|
Varyings ({currentAnalysis.varyings.length})
|
||||||
|
</div>
|
||||||
|
<div className="analysis-list">
|
||||||
|
{currentAnalysis.varyings.map((v, i) => (
|
||||||
|
<div key={i} className="analysis-item">
|
||||||
|
<span className={`item-qualifier ${v.qualifier}`}>{v.qualifier}</span>
|
||||||
|
<span className="item-type">{v.type}</span>
|
||||||
|
<span className="item-name">{v.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tips */}
|
||||||
|
{currentAnalysis.complexity.tips.length > 0 && (
|
||||||
|
<div className="analysis-section">
|
||||||
|
<div className="analysis-section-title">Performance Tips</div>
|
||||||
|
<div className="analysis-tips">
|
||||||
|
{currentAnalysis.complexity.tips.map((tip, i) => (
|
||||||
|
<div key={i} className="analysis-tip">
|
||||||
|
<AlertTriangle size={12} />
|
||||||
|
<span>{tip}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Warnings */}
|
||||||
|
{currentAnalysis.warnings.length > 0 && (
|
||||||
|
<div className="analysis-section">
|
||||||
|
<div className="analysis-section-title">Warnings</div>
|
||||||
|
<div className="analysis-warnings">
|
||||||
|
{currentAnalysis.warnings.map((warning, i) => (
|
||||||
|
<div key={i} className="analysis-warning">
|
||||||
|
<AlertTriangle size={12} />
|
||||||
|
<span>{warning}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="analysis-section">
|
||||||
|
<div className="analysis-section-title">Info</div>
|
||||||
|
<div className="analysis-info">
|
||||||
|
<div className="info-item">
|
||||||
|
<span className="info-label">GLSL Version</span>
|
||||||
|
<span className="info-value">{currentAnalysis.version}</span>
|
||||||
|
</div>
|
||||||
|
<div className="info-item">
|
||||||
|
<span className="info-label">Precision</span>
|
||||||
|
<span className="info-value">{currentAnalysis.precision}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
packages/shader-editor/src/index.ts
Normal file
110
packages/shader-editor/src/index.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* @esengine/shader-editor
|
||||||
|
*
|
||||||
|
* Shader editor with code editing, analysis, and preview.
|
||||||
|
* 着色器编辑器,支持代码编辑、分析和预览。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||||
|
import type {
|
||||||
|
IEditorModuleLoader,
|
||||||
|
IPlugin,
|
||||||
|
ModuleManifest,
|
||||||
|
IFileSystem
|
||||||
|
} from '@esengine/editor-core';
|
||||||
|
import {
|
||||||
|
InspectorRegistry,
|
||||||
|
IInspectorRegistry,
|
||||||
|
IFileSystemService
|
||||||
|
} from '@esengine/editor-core';
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import { useShaderEditorStore } from './stores/ShaderEditorStore';
|
||||||
|
import { ShaderAssetInspectorProvider } from './providers/ShaderAssetInspectorProvider';
|
||||||
|
|
||||||
|
// Import styles
|
||||||
|
import './styles/ShaderInspector.css';
|
||||||
|
|
||||||
|
// Re-exports
|
||||||
|
export { useShaderEditorStore, createDefaultShaderData } from './stores/ShaderEditorStore';
|
||||||
|
export type { ShaderData, ShaderEditorState } from './stores/ShaderEditorStore';
|
||||||
|
export { ShaderAnalyzer, shaderAnalyzer } from './analysis/ShaderAnalyzer';
|
||||||
|
export type {
|
||||||
|
ShaderAnalysis,
|
||||||
|
ShaderComplexity,
|
||||||
|
UniformInfo,
|
||||||
|
AttributeInfo,
|
||||||
|
VaryingInfo
|
||||||
|
} from './analysis/ShaderAnalyzer';
|
||||||
|
export { ShaderAssetInspectorProvider } from './providers/ShaderAssetInspectorProvider';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader Editor Module.
|
||||||
|
* 着色器编辑器模块。
|
||||||
|
*/
|
||||||
|
export class ShaderEditorModule implements IEditorModuleLoader {
|
||||||
|
private unsubscribers: Array<() => void> = [];
|
||||||
|
private inspectorProvider?: ShaderAssetInspectorProvider;
|
||||||
|
|
||||||
|
async install(services: ServiceContainer): Promise<void> {
|
||||||
|
// Register Shader Asset Inspector Provider.
|
||||||
|
// 注册着色器资产检视器提供者。
|
||||||
|
const inspectorRegistry = services.resolve<InspectorRegistry>(IInspectorRegistry);
|
||||||
|
if (inspectorRegistry) {
|
||||||
|
this.inspectorProvider = new ShaderAssetInspectorProvider();
|
||||||
|
|
||||||
|
// Set up save handler.
|
||||||
|
// 设置保存处理器。
|
||||||
|
const fileSystem = services.tryResolve<IFileSystem>(IFileSystemService);
|
||||||
|
if (fileSystem) {
|
||||||
|
this.inspectorProvider.setSaveHandler(async (path, content) => {
|
||||||
|
await fileSystem.writeFile(path, content);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
inspectorRegistry.register(this.inspectorProvider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async uninstall(): Promise<void> {
|
||||||
|
// Clean up subscriptions.
|
||||||
|
this.unsubscribers.forEach(unsub => unsub());
|
||||||
|
this.unsubscribers = [];
|
||||||
|
// Reset store.
|
||||||
|
useShaderEditorStore.getState().reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shaderEditorModule = new ShaderEditorModule();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader Editor Plugin Manifest.
|
||||||
|
* 着色器编辑器插件清单。
|
||||||
|
*/
|
||||||
|
const manifest: ModuleManifest = {
|
||||||
|
id: '@esengine/shader-editor',
|
||||||
|
name: '@esengine/shader-editor',
|
||||||
|
displayName: 'Shader Editor',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'Shader code editing with analysis and preview',
|
||||||
|
category: 'Rendering',
|
||||||
|
isCore: true,
|
||||||
|
defaultEnabled: true,
|
||||||
|
isEngineModule: true,
|
||||||
|
dependencies: ['material-system'],
|
||||||
|
exports: {
|
||||||
|
other: ['ShaderAnalyzer']
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader Editor Plugin (editor only, no runtime).
|
||||||
|
* 着色器编辑器插件(仅编辑器,无运行时)。
|
||||||
|
*/
|
||||||
|
export const ShaderEditorPlugin: IPlugin = {
|
||||||
|
manifest,
|
||||||
|
editorModule: shaderEditorModule
|
||||||
|
};
|
||||||
|
|
||||||
|
export default shaderEditorModule;
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
/**
|
||||||
|
* ShaderAssetInspectorProvider - Inspector provider for .shader files.
|
||||||
|
* 着色器资产检视器提供者 - 用于 .shader 文件的检视器。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import type { IInspectorProvider, InspectorContext } from '@esengine/editor-core';
|
||||||
|
import {
|
||||||
|
Save, RotateCcw, Play, AlertTriangle, CheckCircle,
|
||||||
|
Code, ChevronDown, ChevronRight, BarChart3, FileCode
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { ShaderAnalyzer, ShaderAnalysis } from '../analysis/ShaderAnalyzer';
|
||||||
|
import '../styles/ShaderInspector.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asset file info interface.
|
||||||
|
* 资产文件信息接口。
|
||||||
|
*/
|
||||||
|
interface AssetFileInfo {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
extension?: string;
|
||||||
|
size?: number;
|
||||||
|
modified?: number;
|
||||||
|
isDirectory: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asset file target with content.
|
||||||
|
* 带内容的资产文件目标。
|
||||||
|
*/
|
||||||
|
interface AssetFileTarget {
|
||||||
|
type: 'asset-file';
|
||||||
|
data: AssetFileInfo;
|
||||||
|
content?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader data structure (internal format for editing).
|
||||||
|
* 着色器数据结构(用于编辑的内部格式)。
|
||||||
|
*/
|
||||||
|
interface ShaderData {
|
||||||
|
version?: number;
|
||||||
|
name: string;
|
||||||
|
vertex: string;
|
||||||
|
fragment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader file format (wrapper format).
|
||||||
|
* 着色器文件格式(包装格式)。
|
||||||
|
*/
|
||||||
|
interface ShaderFileFormat {
|
||||||
|
version: number;
|
||||||
|
shader: {
|
||||||
|
name: string;
|
||||||
|
vertexSource: string;
|
||||||
|
fragmentSource: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShaderInspectorViewProps {
|
||||||
|
fileInfo: AssetFileInfo;
|
||||||
|
content: string;
|
||||||
|
onSave?: (path: string, content: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader Inspector View Component.
|
||||||
|
* 着色器检视器视图组件。
|
||||||
|
*/
|
||||||
|
function ShaderInspectorView({ fileInfo, content, onSave }: ShaderInspectorViewProps) {
|
||||||
|
const [shader, setShader] = useState<ShaderData | null>(null);
|
||||||
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<'vertex' | 'fragment'>('fragment');
|
||||||
|
const [vertexAnalysis, setVertexAnalysis] = useState<ShaderAnalysis | null>(null);
|
||||||
|
const [fragmentAnalysis, setFragmentAnalysis] = useState<ShaderAnalysis | null>(null);
|
||||||
|
const [analysisExpanded, setAnalysisExpanded] = useState(true);
|
||||||
|
const [compileStatus, setCompileStatus] = useState<'none' | 'success' | 'error'>('none');
|
||||||
|
const [compileError, setCompileError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const analyzer = useRef(new ShaderAnalyzer());
|
||||||
|
|
||||||
|
// Parse shader content.
|
||||||
|
// 解析着色器内容。
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content) as ShaderFileFormat;
|
||||||
|
// Convert from file format to internal format.
|
||||||
|
// 从文件格式转换为内部格式。
|
||||||
|
setShader({
|
||||||
|
version: parsed.version,
|
||||||
|
name: parsed.shader.name,
|
||||||
|
vertex: parsed.shader.vertexSource,
|
||||||
|
fragment: parsed.shader.fragmentSource
|
||||||
|
});
|
||||||
|
setError(null);
|
||||||
|
setIsDirty(false);
|
||||||
|
setCompileStatus('none');
|
||||||
|
} catch (e) {
|
||||||
|
setError('Failed to parse shader file');
|
||||||
|
setShader(null);
|
||||||
|
}
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
// Analyze shader when source changes.
|
||||||
|
// 当源代码改变时分析着色器。
|
||||||
|
useEffect(() => {
|
||||||
|
if (shader) {
|
||||||
|
setVertexAnalysis(analyzer.current.analyze(shader.vertex || '', true));
|
||||||
|
setFragmentAnalysis(analyzer.current.analyze(shader.fragment || '', false));
|
||||||
|
}
|
||||||
|
}, [shader?.vertex, shader?.fragment]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!shader || !onSave) return;
|
||||||
|
try {
|
||||||
|
// Convert internal format back to file format.
|
||||||
|
// 将内部格式转换回文件格式。
|
||||||
|
const fileData: ShaderFileFormat = {
|
||||||
|
version: shader.version || 1,
|
||||||
|
shader: {
|
||||||
|
name: shader.name,
|
||||||
|
vertexSource: shader.vertex,
|
||||||
|
fragmentSource: shader.fragment
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const jsonContent = JSON.stringify(fileData, null, 2);
|
||||||
|
await onSave(fileInfo.path, jsonContent);
|
||||||
|
setIsDirty(false);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ShaderInspector] Failed to save:', e);
|
||||||
|
}
|
||||||
|
}, [shader, fileInfo.path, onSave]);
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content) as ShaderFileFormat;
|
||||||
|
setShader({
|
||||||
|
version: parsed.version,
|
||||||
|
name: parsed.shader.name,
|
||||||
|
vertex: parsed.shader.vertexSource,
|
||||||
|
fragment: parsed.shader.fragmentSource
|
||||||
|
});
|
||||||
|
setIsDirty(false);
|
||||||
|
setCompileStatus('none');
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
const handleSourceChange = (type: 'vertex' | 'fragment', value: string) => {
|
||||||
|
if (!shader) return;
|
||||||
|
setShader({ ...shader, [type]: value });
|
||||||
|
setIsDirty(true);
|
||||||
|
setCompileStatus('none');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCompile = async () => {
|
||||||
|
if (!shader) return;
|
||||||
|
|
||||||
|
setCompileStatus('none');
|
||||||
|
setCompileError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Dynamic import to avoid circular dependencies.
|
||||||
|
// 动态导入避免循环依赖。
|
||||||
|
const { getMaterialManager, Shader } = await import('@esengine/material-system');
|
||||||
|
const materialManager = getMaterialManager();
|
||||||
|
|
||||||
|
if (!materialManager) {
|
||||||
|
setCompileError('MaterialManager not available');
|
||||||
|
setCompileStatus('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test shader.
|
||||||
|
// 创建测试着色器。
|
||||||
|
const testShader = new Shader(
|
||||||
|
`test_${Date.now()}`,
|
||||||
|
shader.vertex,
|
||||||
|
shader.fragment
|
||||||
|
);
|
||||||
|
|
||||||
|
const shaderId = await materialManager.registerShader(testShader);
|
||||||
|
if (shaderId > 0) {
|
||||||
|
setCompileStatus('success');
|
||||||
|
materialManager.removeShader(shaderId);
|
||||||
|
} else {
|
||||||
|
setCompileError('Compilation failed');
|
||||||
|
setCompileStatus('error');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setCompileError(err.message || 'Compilation failed');
|
||||||
|
setCompileStatus('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentAnalysis = activeTab === 'vertex' ? vertexAnalysis : fragmentAnalysis;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="entity-inspector shader-inspector">
|
||||||
|
<div className="inspector-header">
|
||||||
|
<FileCode size={16} style={{ color: '#60a5fa' }} />
|
||||||
|
<span className="entity-name">{fileInfo.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="inspector-content">
|
||||||
|
<div className="shader-error">{error}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shader) {
|
||||||
|
return (
|
||||||
|
<div className="entity-inspector shader-inspector">
|
||||||
|
<div className="inspector-header">
|
||||||
|
<FileCode size={16} style={{ color: '#60a5fa' }} />
|
||||||
|
<span className="entity-name">{fileInfo.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="inspector-content">
|
||||||
|
<div className="shader-loading">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="entity-inspector shader-inspector">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="inspector-header">
|
||||||
|
<FileCode size={16} style={{ color: '#60a5fa' }} />
|
||||||
|
<span className="entity-name">{shader.name || fileInfo.name}</span>
|
||||||
|
{isDirty && <span className="shader-dirty-indicator">*</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="shader-toolbar">
|
||||||
|
<button
|
||||||
|
className="shader-toolbar-btn"
|
||||||
|
onClick={handleCompile}
|
||||||
|
title="Compile shader"
|
||||||
|
>
|
||||||
|
<Play size={14} />
|
||||||
|
<span>Compile</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="shader-toolbar-btn"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!isDirty || !onSave}
|
||||||
|
title="Save"
|
||||||
|
>
|
||||||
|
<Save size={14} />
|
||||||
|
<span>Save</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="shader-toolbar-btn"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={!isDirty}
|
||||||
|
title="Reset"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} />
|
||||||
|
<span>Reset</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compile Status */}
|
||||||
|
{compileStatus === 'success' && (
|
||||||
|
<div className="shader-status success">
|
||||||
|
<CheckCircle size={14} />
|
||||||
|
<span>Compilation successful!</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{compileStatus === 'error' && (
|
||||||
|
<div className="shader-status error">
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
<span>{compileError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="inspector-content">
|
||||||
|
{/* Basic Properties */}
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div className="section-title">Properties</div>
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="shader-input"
|
||||||
|
value={shader.name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setShader({ ...shader, name: e.target.value });
|
||||||
|
setIsDirty(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="property-field">
|
||||||
|
<label className="property-label">Version</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="shader-input"
|
||||||
|
value={shader.version || 1}
|
||||||
|
onChange={(e) => {
|
||||||
|
setShader({ ...shader, version: parseInt(e.target.value, 10) || 1 });
|
||||||
|
setIsDirty(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shader Source Tabs */}
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div className="section-title">Source Code</div>
|
||||||
|
<div className="shader-tabs">
|
||||||
|
<button
|
||||||
|
className={`shader-tab ${activeTab === 'vertex' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('vertex')}
|
||||||
|
>
|
||||||
|
<Code size={12} />
|
||||||
|
Vertex
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`shader-tab ${activeTab === 'fragment' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveTab('fragment')}
|
||||||
|
>
|
||||||
|
<Code size={12} />
|
||||||
|
Fragment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="shader-code-wrapper">
|
||||||
|
<textarea
|
||||||
|
className="shader-code-editor"
|
||||||
|
value={activeTab === 'vertex' ? shader.vertex : shader.fragment}
|
||||||
|
onChange={e => handleSourceChange(activeTab, e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder={`Enter ${activeTab} shader code...`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Analysis Section */}
|
||||||
|
<div className="inspector-section">
|
||||||
|
<div
|
||||||
|
className="section-title section-title-collapsible"
|
||||||
|
onClick={() => setAnalysisExpanded(!analysisExpanded)}
|
||||||
|
>
|
||||||
|
{analysisExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
|
<BarChart3 size={14} />
|
||||||
|
<span>Analysis</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{analysisExpanded && currentAnalysis && (
|
||||||
|
<div className="shader-analysis">
|
||||||
|
{/* Complexity Badge */}
|
||||||
|
<div className="analysis-row">
|
||||||
|
<span className="analysis-label">Complexity</span>
|
||||||
|
<span className={`complexity-badge ${currentAnalysis.complexity.level}`}>
|
||||||
|
{currentAnalysis.complexity.level.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics */}
|
||||||
|
<div className="analysis-row">
|
||||||
|
<span className="analysis-label">Instructions</span>
|
||||||
|
<span className="analysis-value">~{currentAnalysis.complexity.instructionCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="analysis-row">
|
||||||
|
<span className="analysis-label">Texture Samples</span>
|
||||||
|
<span className="analysis-value">{currentAnalysis.complexity.textureSamples}</span>
|
||||||
|
</div>
|
||||||
|
<div className="analysis-row">
|
||||||
|
<span className="analysis-label">Branches</span>
|
||||||
|
<span className="analysis-value">{currentAnalysis.complexity.branches}</span>
|
||||||
|
</div>
|
||||||
|
<div className="analysis-row">
|
||||||
|
<span className="analysis-label">Loops</span>
|
||||||
|
<span className="analysis-value">{currentAnalysis.complexity.loops}</span>
|
||||||
|
</div>
|
||||||
|
<div className="analysis-row">
|
||||||
|
<span className="analysis-label">Math Ops</span>
|
||||||
|
<span className="analysis-value">{currentAnalysis.complexity.mathOps}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Uniforms */}
|
||||||
|
{currentAnalysis.uniforms.length > 0 && (
|
||||||
|
<div className="analysis-group">
|
||||||
|
<div className="analysis-group-title">
|
||||||
|
Uniforms ({currentAnalysis.uniforms.length})
|
||||||
|
</div>
|
||||||
|
{currentAnalysis.uniforms.map((u, i) => (
|
||||||
|
<div key={i} className="analysis-item">
|
||||||
|
<span className="item-type">{u.type}</span>
|
||||||
|
<span className="item-name">{u.name}</span>
|
||||||
|
{u.arraySize && <span className="item-array">[{u.arraySize}]</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attributes (vertex only) */}
|
||||||
|
{activeTab === 'vertex' && currentAnalysis.attributes.length > 0 && (
|
||||||
|
<div className="analysis-group">
|
||||||
|
<div className="analysis-group-title">
|
||||||
|
Attributes ({currentAnalysis.attributes.length})
|
||||||
|
</div>
|
||||||
|
{currentAnalysis.attributes.map((a, i) => (
|
||||||
|
<div key={i} className="analysis-item">
|
||||||
|
<span className="item-type">{a.type}</span>
|
||||||
|
<span className="item-name">{a.name}</span>
|
||||||
|
{a.location !== undefined && (
|
||||||
|
<span className="item-location">loc={a.location}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Varyings */}
|
||||||
|
{currentAnalysis.varyings.length > 0 && (
|
||||||
|
<div className="analysis-group">
|
||||||
|
<div className="analysis-group-title">
|
||||||
|
Varyings ({currentAnalysis.varyings.length})
|
||||||
|
</div>
|
||||||
|
{currentAnalysis.varyings.map((v, i) => (
|
||||||
|
<div key={i} className="analysis-item">
|
||||||
|
<span className={`item-qualifier ${v.qualifier}`}>{v.qualifier}</span>
|
||||||
|
<span className="item-type">{v.type}</span>
|
||||||
|
<span className="item-name">{v.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tips */}
|
||||||
|
{currentAnalysis.complexity.tips.length > 0 && (
|
||||||
|
<div className="analysis-group">
|
||||||
|
<div className="analysis-group-title">Performance Tips</div>
|
||||||
|
{currentAnalysis.complexity.tips.map((tip, i) => (
|
||||||
|
<div key={i} className="analysis-tip">
|
||||||
|
<AlertTriangle size={12} />
|
||||||
|
<span>{tip}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Warnings */}
|
||||||
|
{currentAnalysis.warnings.length > 0 && (
|
||||||
|
<div className="analysis-group">
|
||||||
|
<div className="analysis-group-title">Warnings</div>
|
||||||
|
{currentAnalysis.warnings.map((warning, i) => (
|
||||||
|
<div key={i} className="analysis-warning">
|
||||||
|
<AlertTriangle size={12} />
|
||||||
|
<span>{warning}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="analysis-group">
|
||||||
|
<div className="analysis-group-title">Info</div>
|
||||||
|
<div className="analysis-row">
|
||||||
|
<span className="analysis-label">GLSL Version</span>
|
||||||
|
<span className="analysis-value">{currentAnalysis.version}</span>
|
||||||
|
</div>
|
||||||
|
<div className="analysis-row">
|
||||||
|
<span className="analysis-label">Precision</span>
|
||||||
|
<span className="analysis-value">{currentAnalysis.precision}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader Asset Inspector Provider.
|
||||||
|
* 着色器资产检视器提供者。
|
||||||
|
*/
|
||||||
|
export class ShaderAssetInspectorProvider implements IInspectorProvider<AssetFileTarget> {
|
||||||
|
readonly id = 'shader-asset-inspector';
|
||||||
|
readonly name = 'Shader Asset Inspector';
|
||||||
|
readonly priority = 100;
|
||||||
|
|
||||||
|
private saveHandler?: (path: string, content: string) => Promise<void>;
|
||||||
|
|
||||||
|
setSaveHandler(handler: (path: string, content: string) => Promise<void>): void {
|
||||||
|
this.saveHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
canHandle(target: unknown): target is AssetFileTarget {
|
||||||
|
if (typeof target !== 'object' || target === null) return false;
|
||||||
|
const t = target as any;
|
||||||
|
return t.type === 'asset-file' &&
|
||||||
|
t.data?.extension?.toLowerCase() === 'shader' &&
|
||||||
|
typeof t.content === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
render(target: AssetFileTarget, _context: InspectorContext): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<ShaderInspectorView
|
||||||
|
fileInfo={target.data}
|
||||||
|
content={target.content!}
|
||||||
|
onSave={this.saveHandler}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
105
packages/shader-editor/src/stores/ShaderEditorStore.ts
Normal file
105
packages/shader-editor/src/stores/ShaderEditorStore.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Shader Editor Store.
|
||||||
|
* 着色器编辑器状态存储。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader data structure.
|
||||||
|
* 着色器数据结构。
|
||||||
|
*/
|
||||||
|
export interface ShaderData {
|
||||||
|
version: string;
|
||||||
|
name: string;
|
||||||
|
vertex: string;
|
||||||
|
fragment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader editor state.
|
||||||
|
* 着色器编辑器状态。
|
||||||
|
*/
|
||||||
|
export interface ShaderEditorState {
|
||||||
|
/** Current file path. | 当前文件路径。 */
|
||||||
|
filePath: string | null;
|
||||||
|
/** Shader data. | 着色器数据。 */
|
||||||
|
shaderData: ShaderData | null;
|
||||||
|
/** Whether data has been modified. | 数据是否已修改。 */
|
||||||
|
isDirty: boolean;
|
||||||
|
|
||||||
|
/** Set file path. | 设置文件路径。 */
|
||||||
|
setFilePath: (path: string | null) => void;
|
||||||
|
/** Set shader data. | 设置着色器数据。 */
|
||||||
|
setShaderData: (data: ShaderData | null) => void;
|
||||||
|
/** Set dirty flag. | 设置修改标志。 */
|
||||||
|
setDirty: (dirty: boolean) => void;
|
||||||
|
/** Reset state. | 重置状态。 */
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create default shader data.
|
||||||
|
* 创建默认着色器数据。
|
||||||
|
*/
|
||||||
|
export function createDefaultShaderData(name: string = 'New Shader'): ShaderData {
|
||||||
|
return {
|
||||||
|
version: '1.0',
|
||||||
|
name,
|
||||||
|
vertex: `#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
layout(location = 0) in vec2 a_position;
|
||||||
|
layout(location = 1) in vec2 a_texCoord;
|
||||||
|
layout(location = 2) in vec4 a_color;
|
||||||
|
|
||||||
|
uniform mat3 u_projection;
|
||||||
|
|
||||||
|
out vec2 v_texCoord;
|
||||||
|
out vec4 v_color;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec3 pos = u_projection * vec3(a_position, 1.0);
|
||||||
|
gl_Position = vec4(pos.xy, 0.0, 1.0);
|
||||||
|
v_texCoord = a_texCoord;
|
||||||
|
v_color = a_color;
|
||||||
|
}`,
|
||||||
|
fragment: `#version 300 es
|
||||||
|
precision highp float;
|
||||||
|
|
||||||
|
in vec2 v_texCoord;
|
||||||
|
in vec4 v_color;
|
||||||
|
|
||||||
|
uniform sampler2D u_texture;
|
||||||
|
|
||||||
|
out vec4 fragColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
vec4 texColor = texture(u_texture, v_texCoord);
|
||||||
|
fragColor = texColor * v_color;
|
||||||
|
|
||||||
|
if (fragColor.a < 0.01) {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shader editor store.
|
||||||
|
* 着色器编辑器存储。
|
||||||
|
*/
|
||||||
|
export const useShaderEditorStore = create<ShaderEditorState>((set) => ({
|
||||||
|
filePath: null,
|
||||||
|
shaderData: null,
|
||||||
|
isDirty: false,
|
||||||
|
|
||||||
|
setFilePath: (path) => set({ filePath: path }),
|
||||||
|
setShaderData: (data) => set({ shaderData: data }),
|
||||||
|
setDirty: (dirty) => set({ isDirty: dirty }),
|
||||||
|
reset: () => set({
|
||||||
|
filePath: null,
|
||||||
|
shaderData: null,
|
||||||
|
isDirty: false
|
||||||
|
})
|
||||||
|
}));
|
||||||
286
packages/shader-editor/src/styles/ShaderInspector.css
Normal file
286
packages/shader-editor/src/styles/ShaderInspector.css
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
/**
|
||||||
|
* Shader Inspector Styles.
|
||||||
|
* 着色器检视器样式。
|
||||||
|
*/
|
||||||
|
|
||||||
|
.shader-inspector {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-dirty-indicator {
|
||||||
|
color: var(--warning-color, #fbbf24);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-error,
|
||||||
|
.shader-loading {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-error {
|
||||||
|
color: var(--error-color, #f87171);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.shader-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color, #333);
|
||||||
|
background: var(--bg-secondary, #252526);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-toolbar-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid var(--border-color, #444);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--button-bg, #333);
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-toolbar-btn:hover:not(:disabled) {
|
||||||
|
background: var(--button-hover-bg, #444);
|
||||||
|
border-color: var(--accent-color, #0078d4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-toolbar-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
.shader-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-status.success {
|
||||||
|
background: var(--success-bg, #1a3a1a);
|
||||||
|
color: var(--success-color, #4ade80);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-status.error {
|
||||||
|
background: var(--error-bg, #3a2020);
|
||||||
|
color: var(--error-color, #f87171);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input */
|
||||||
|
.shader-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--input-bg, #333);
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color, #0078d4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.shader-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--border-color, #333);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-tab:hover {
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-tab.active {
|
||||||
|
color: var(--accent-color, #0078d4);
|
||||||
|
border-bottom-color: var(--accent-color, #0078d4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code Editor */
|
||||||
|
.shader-code-wrapper {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid var(--border-color, #333);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-code-editor {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: 400px;
|
||||||
|
padding: 8px;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-tertiary, #1a1a1a);
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
tab-size: 4;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-code-editor::placeholder {
|
||||||
|
color: var(--text-tertiary, #555);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Analysis */
|
||||||
|
.shader-analysis {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-label {
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-value {
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Complexity Badge */
|
||||||
|
.complexity-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.complexity-badge.low {
|
||||||
|
background: var(--success-bg, #1a3a1a);
|
||||||
|
color: var(--success-color, #4ade80);
|
||||||
|
}
|
||||||
|
|
||||||
|
.complexity-badge.medium {
|
||||||
|
background: var(--info-bg, #1a2a3a);
|
||||||
|
color: var(--info-color, #60a5fa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.complexity-badge.high {
|
||||||
|
background: var(--warning-bg, #3a3a1a);
|
||||||
|
color: var(--warning-color, #fbbf24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.complexity-badge.very-high {
|
||||||
|
background: var(--error-bg, #3a2020);
|
||||||
|
color: var(--error-color, #f87171);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Analysis Groups */
|
||||||
|
.analysis-group {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--border-color, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-group-title {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #888);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Analysis Items */
|
||||||
|
.analysis-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--bg-tertiary, #1e1e1e);
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-type {
|
||||||
|
color: var(--accent-color, #0078d4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-array {
|
||||||
|
color: var(--text-tertiary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-location {
|
||||||
|
color: var(--text-tertiary, #666);
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-qualifier {
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-qualifier.in {
|
||||||
|
background: var(--success-bg, #1a3a1a);
|
||||||
|
color: var(--success-color, #4ade80);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-qualifier.out {
|
||||||
|
background: var(--info-bg, #1a2a3a);
|
||||||
|
color: var(--info-color, #60a5fa);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tips and Warnings */
|
||||||
|
.analysis-tip,
|
||||||
|
.analysis-warning {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.4;
|
||||||
|
background: var(--warning-bg, #3a3a1a);
|
||||||
|
color: var(--warning-color, #fbbf24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis-tip svg,
|
||||||
|
.analysis-warning svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
9
packages/shader-editor/tsconfig.build.json
Normal file
9
packages/shader-editor/tsconfig.build.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": false,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationDir": "./dist"
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||||
|
}
|
||||||
16
packages/shader-editor/tsconfig.json
Normal file
16
packages/shader-editor/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"],
|
||||||
|
"references": [
|
||||||
|
{ "path": "../core" },
|
||||||
|
{ "path": "../editor-core" },
|
||||||
|
{ "path": "../material-system" }
|
||||||
|
]
|
||||||
|
}
|
||||||
7
packages/shader-editor/tsup.config.ts
Normal file
7
packages/shader-editor/tsup.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'tsup';
|
||||||
|
import { editorOnlyPreset } from '../build-config/src/presets/plugin-tsup';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
...editorOnlyPreset(),
|
||||||
|
tsconfig: 'tsconfig.build.json'
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user