diff --git a/packages/material-editor/package.json b/packages/material-editor/package.json new file mode 100644 index 00000000..4d1b98b1 --- /dev/null +++ b/packages/material-editor/package.json @@ -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" +} diff --git a/packages/material-editor/src/components/MaterialEditorPanel.tsx b/packages/material-editor/src/components/MaterialEditorPanel.tsx new file mode 100644 index 00000000..d0f11a9a --- /dev/null +++ b/packages/material-editor/src/components/MaterialEditorPanel.tsx @@ -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; + writeFile(path: string, content: string): Promise; +}; + +/** + * 混合模式选项 + */ +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 ( +
+ + {isZh ? '加载中...' : 'Loading...'} +
+ ); + } + + // 渲染空状态 + if (!materialData) { + return ( +
+ {isZh ? '双击 .mat 文件打开材质编辑器' : 'Double-click a .mat file to open the material editor'} +
+ ); + } + + return ( +
+ {/* 工具栏 */} +
+
+ {materialData.name} + {isDirty && *} +
+
+ +
+
+ + {/* 属性编辑区 */} +
+ {/* 基本属性 */} +
+
{isZh ? '基本属性' : 'Basic Properties'}
+ +
+ + updateMaterialProperty('name', e.target.value)} + /> +
+ +
+ +
+ +
+
+ + {/* Custom shader path input */} + {typeof materialData.shader === 'string' && ( +
+ +
+ updateMaterialProperty('shader', e.target.value)} + placeholder={isZh ? '输入 .shader 文件路径' : 'Enter .shader file path'} + /> + +
+
+ )} + +
+ + +
+
+ + {/* Uniform 参数 */} +
+
{isZh ? 'Uniform 参数' : 'Uniform Parameters'}
+ + {Object.keys(materialData.uniforms || {}).length === 0 ? ( +
+ {isZh ? '该着色器没有自定义参数' : 'This shader has no custom parameters'} +
+ ) : ( + Object.entries(materialData.uniforms || {}).map(([key, uniform]) => ( +
+ + {uniform.type} +
+ )) + )} +
+ + {/* 文件信息 */} +
+
{isZh ? '文件信息' : 'File Info'}
+
+ + + {currentFilePath?.split(/[\\/]/).pop() || '-'} + +
+
+
+
+ ); +} + +export default MaterialEditorPanel; diff --git a/packages/material-editor/src/index.ts b/packages/material-editor/src/index.ts new file mode 100644 index 00000000..91f9ef73 --- /dev/null +++ b/packages/material-editor/src/index.ts @@ -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 { + // 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(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 } | 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 { + // 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 { + // Import dialog and file system services dynamically + const { IDialogService, IFileSystemService } = await import('@esengine/editor-core'); + type IDialog = { saveDialog(options: any): Promise }; + type IFileSystem = { writeFile(path: string, content: string): Promise }; + + 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; diff --git a/packages/material-editor/src/providers/MaterialAssetInspectorProvider.tsx b/packages/material-editor/src/providers/MaterialAssetInspectorProvider.tsx new file mode 100644 index 00000000..f3f4c059 --- /dev/null +++ b/packages/material-editor/src/providers/MaterialAssetInspectorProvider.tsx @@ -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; +} + +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; +} + +function MaterialInspectorView({ fileInfo, content, onSave }: MaterialInspectorViewProps) { + const [material, setMaterial] = useState(null); + const [isDirty, setIsDirty] = useState(false); + const [error, setError] = useState(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) => { + 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 ( + 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 ( +
+ {values.map((v, i) => ( +
+ {labels[i]} + { + const newValues = [...values]; + newValues[i] = parseFloat(e.target.value) || 0; + updateUniform(name, { ...uniform, value: newValues }); + }} + /> +
+ ))} +
+ ); + } + 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 ( +
+ { + 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] }); + }} + /> + { + const newAlpha = parseFloat(e.target.value) || 0; + updateUniform(name, { ...uniform, value: [colorValues[0] ?? 1, colorValues[1] ?? 1, colorValues[2] ?? 1, newAlpha] }); + }} + /> +
+ ); + } + default: + return {JSON.stringify(uniform.value)}; + } + }; + + return ( +
+
+ {name} + + +
+
+ {renderValueEditor()} +
+
+ ); + }; + + if (error) { + return ( +
+
+ + {fileInfo.name} +
+
+
{error}
+
+
+ ); + } + + if (!material) { + return ( +
+
+ + {fileInfo.name} +
+
+
Loading...
+
+
+ ); + } + + return ( +
+
+ + {material.name || fileInfo.name} + {isDirty && *} +
+ +
+ + +
+ +
+
+
Basic Properties
+ +
+ + updateMaterial({ name: e.target.value })} + /> +
+ +
+ + +
+ +
+ + +
+
+ +
+
setUniformsExpanded(!uniformsExpanded)} + > + {uniformsExpanded ? : } + Uniforms + + ({Object.keys(material.uniforms || {}).length}) + +
+ + {uniformsExpanded && ( +
+ {material.uniforms && Object.entries(material.uniforms).map(([name, uniform]) => + renderUniformEditor(name, uniform) + )} + +
+ setNewUniformName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && addUniform()} + /> + +
+
+ )} +
+
+
+ ); +} + +/** + * Material Asset Inspector Provider + */ +export class MaterialAssetInspectorProvider implements IInspectorProvider { + readonly id = 'material-asset-inspector'; + readonly name = 'Material Asset Inspector'; + readonly priority = 100; + + private saveHandler?: (path: string, content: string) => Promise; + + setSaveHandler(handler: (path: string, content: string) => Promise): 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 ( + + ); + } +} diff --git a/packages/material-editor/src/stores/MaterialEditorStore.ts b/packages/material-editor/src/stores/MaterialEditorStore.ts new file mode 100644 index 00000000..85077b9e --- /dev/null +++ b/packages/material-editor/src/stores/MaterialEditorStore.ts @@ -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: (key: K, value: MaterialDefinition[K]) => void; + reset: () => void; +} + +const initialState = { + currentFilePath: null, + pendingFilePath: null, + materialData: null, + isDirty: false, + isLoading: false, +}; + +export const useMaterialEditorStore = create((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: {}, + }; +} diff --git a/packages/material-editor/src/styles/MaterialEditorPanel.css b/packages/material-editor/src/styles/MaterialEditorPanel.css new file mode 100644 index 00000000..abb7781f --- /dev/null +++ b/packages/material-editor/src/styles/MaterialEditorPanel.css @@ -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); +} diff --git a/packages/material-editor/src/styles/MaterialInspector.css b/packages/material-editor/src/styles/MaterialInspector.css new file mode 100644 index 00000000..e4ebfa32 --- /dev/null +++ b/packages/material-editor/src/styles/MaterialInspector.css @@ -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; +} diff --git a/packages/material-editor/tsconfig.build.json b/packages/material-editor/tsconfig.build.json new file mode 100644 index 00000000..db576def --- /dev/null +++ b/packages/material-editor/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "declarationMap": false + }, + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/packages/material-editor/tsconfig.json b/packages/material-editor/tsconfig.json new file mode 100644 index 00000000..56369c94 --- /dev/null +++ b/packages/material-editor/tsconfig.json @@ -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" } + ] +} diff --git a/packages/material-editor/tsup.config.ts b/packages/material-editor/tsup.config.ts new file mode 100644 index 00000000..b4f49f5d --- /dev/null +++ b/packages/material-editor/tsup.config.ts @@ -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' +}); diff --git a/packages/material-system/module.json b/packages/material-system/module.json new file mode 100644 index 00000000..21bc3e7a --- /dev/null +++ b/packages/material-system/module.json @@ -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" +} diff --git a/packages/material-system/package.json b/packages/material-system/package.json new file mode 100644 index 00000000..fb39b485 --- /dev/null +++ b/packages/material-system/package.json @@ -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" +} diff --git a/packages/material-system/src/Material.ts b/packages/material-system/src/Material.ts new file mode 100644 index 00000000..374c2b3a --- /dev/null +++ b/packages/material-system/src/Material.ts @@ -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 = new Map(); + + /** Texture references by uniform name | 按uniform名称引用的纹理 */ + private textures: Map = 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 { + return this.uniforms; + } + + /** + * Get all textures. + * 获取所有纹理。 + */ + getTextures(): Map { + return this.textures; + } + + // ============= Serialization ============= + // ============= 序列化 ============= + + /** + * Export to material definition. + * 导出为材质定义。 + */ + toDefinition(): MaterialDefinition { + const uniformsObj: Record = {}; + for (const [key, value] of this.uniforms) { + uniformsObj[key] = value; + } + + const texturesObj: Record = {}; + 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; + } +} diff --git a/packages/material-system/src/MaterialManager.ts b/packages/material-system/src/MaterialManager.ts new file mode 100644 index 00000000..bb50de30 --- /dev/null +++ b/packages/material-system/src/MaterialManager.ts @@ -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; + compileShaderWithId(shaderId: number, vertexSource: string, fragmentSource: string): Promise; + 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 = new Map(); + + /** Shader name to ID mapping. | 着色器名称到ID的映射。 */ + private shaderNameToId: Map = new Map(); + + /** Registered materials. | 已注册的材质。 */ + private materials: Map = new Map(); + + /** Material name to ID mapping. | 材质名称到ID的映射。 */ + private materialNameToId: Map = new Map(); + + /** Material path to ID mapping. | 材质路径到ID的映射。 */ + private materialPathToId: Map = new Map(); + + /** Shader path to ID mapping. | 着色器路径到ID的映射。 */ + private shaderPathToId: Map = new Map(); + + /** Pending material loads (path -> promise). | 等待加载的材质(路径 -> Promise)。 */ + private pendingMaterialLoads: Map> = new Map(); + + /** Pending shader loads (path -> promise). | 等待加载的着色器(路径 -> Promise)。 */ + private pendingShaderLoads: Map> = 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 { + 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 { + // 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 { + 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(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 { + // 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 { + 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(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> { + const results = new Map(); + + 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; +} diff --git a/packages/material-system/src/MaterialSystemPlugin.ts b/packages/material-system/src/MaterialSystemPlugin.ts new file mode 100644 index 00000000..5beec49d --- /dev/null +++ b/packages/material-system/src/MaterialSystemPlugin.ts @@ -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; + 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 { + 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 +}; diff --git a/packages/material-system/src/Shader.ts b/packages/material-system/src/Shader.ts new file mode 100644 index 00000000..d2317138 --- /dev/null +++ b/packages/material-system/src/Shader.ts @@ -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 = 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 { + return this.uniforms; + } + + /** + * Export to shader definition. + * 导出为着色器定义。 + */ + toDefinition(): ShaderDefinition { + const uniformsObj: Record = {}; + 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; + } +} +`; diff --git a/packages/material-system/src/index.ts b/packages/material-system/src/index.ts new file mode 100644 index 00000000..1097e11a --- /dev/null +++ b/packages/material-system/src/index.ts @@ -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'; diff --git a/packages/material-system/src/loaders/MaterialLoader.ts b/packages/material-system/src/loaders/MaterialLoader.ts new file mode 100644 index 00000000..847869a7 --- /dev/null +++ b/packages/material-system/src/loaders/MaterialLoader.ts @@ -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; +} + +/** + * Material file loader. + * 材质文件加载器。 + */ +export class MaterialLoader implements IAssetLoader { + readonly supportedType = AssetType.Material; + readonly supportedExtensions = ['.mat']; + readonly contentType: AssetContentType = 'text'; + + /** + * Parse material from content. + * 从内容解析材质。 + */ + async parse(content: IAssetContent, context: IAssetParseContext): Promise { + 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. + } +} diff --git a/packages/material-system/src/loaders/ShaderLoader.ts b/packages/material-system/src/loaders/ShaderLoader.ts new file mode 100644 index 00000000..7dcc9665 --- /dev/null +++ b/packages/material-system/src/loaders/ShaderLoader.ts @@ -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 { + readonly supportedType = AssetType.Shader; + readonly supportedExtensions = ['.shader']; + readonly contentType: AssetContentType = 'text'; + + /** + * Parse shader from content. + * 从内容解析着色器。 + */ + async parse(content: IAssetContent, context: IAssetParseContext): Promise { + 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. + } +} diff --git a/packages/material-system/src/types.ts b/packages/material-system/src/types.ts new file mode 100644 index 00000000..0ca5e2c3 --- /dev/null +++ b/packages/material-system/src/types.ts @@ -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; +} + +/** + * 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; + /** Texture references by uniform name | 按uniform名称引用的纹理 */ + textures?: Record; +} + +/** + * 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; diff --git a/packages/material-system/tsconfig.build.json b/packages/material-system/tsconfig.build.json new file mode 100644 index 00000000..f39a0594 --- /dev/null +++ b/packages/material-system/tsconfig.build.json @@ -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"] +} diff --git a/packages/material-system/tsconfig.json b/packages/material-system/tsconfig.json new file mode 100644 index 00000000..d0a51d14 --- /dev/null +++ b/packages/material-system/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "composite": true, + "noEmit": true + } +} diff --git a/packages/material-system/tsup.config.ts b/packages/material-system/tsup.config.ts new file mode 100644 index 00000000..f704a430 --- /dev/null +++ b/packages/material-system/tsup.config.ts @@ -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' +}); diff --git a/packages/shader-editor/package.json b/packages/shader-editor/package.json new file mode 100644 index 00000000..3f51cde7 --- /dev/null +++ b/packages/shader-editor/package.json @@ -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" +} diff --git a/packages/shader-editor/src/analysis/ShaderAnalyzer.ts b/packages/shader-editor/src/analysis/ShaderAnalyzer.ts new file mode 100644 index 00000000..42bfb421 --- /dev/null +++ b/packages/shader-editor/src/analysis/ShaderAnalyzer.ts @@ -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 = { + '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 = /(? 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(); diff --git a/packages/shader-editor/src/components/ShaderEditorPanel.css b/packages/shader-editor/src/components/ShaderEditorPanel.css new file mode 100644 index 00000000..63fa6a89 --- /dev/null +++ b/packages/shader-editor/src/components/ShaderEditorPanel.css @@ -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); +} diff --git a/packages/shader-editor/src/components/ShaderEditorPanel.tsx b/packages/shader-editor/src/components/ShaderEditorPanel.tsx new file mode 100644 index 00000000..3ed685d8 --- /dev/null +++ b/packages/shader-editor/src/components/ShaderEditorPanel.tsx @@ -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(null); + const [fragmentAnalysis, setFragmentAnalysis] = useState(null); + const [compileError, setCompileError] = useState(null); + const [compileSuccess, setCompileSuccess] = useState(false); + + const textareaRef = useRef(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(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(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 ( +
+ +

No shader loaded

+

Open a .shader file to edit

+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ + {shaderData.name} + {isDirty && *} +
+
+ + +
+
+ + {/* Compile status */} + {compileError && ( +
+ + {compileError} +
+ )} + {compileSuccess && ( +
+ + Compilation successful! +
+ )} + + {/* Main content */} +
+ {/* Code editor */} +
+ {/* Tabs */} +
+ + +
+ + {/* Editor */} +
+