feat: 添加跨平台运行时、资产系统和UI适配功能 (#256)
* feat(platform-common): 添加WASM加载器和环境检测API * feat(rapier2d): 新增Rapier2D WASM绑定包 * feat(physics-rapier2d): 添加跨平台WASM加载器 * feat(asset-system): 添加运行时资产目录和bundle格式 * feat(asset-system-editor): 新增编辑器资产管理包 * feat(editor-core): 添加构建系统和模块管理 * feat(editor-app): 重构浏览器预览使用import maps * feat(platform-web): 添加BrowserRuntime和资产读取 * feat(engine): 添加材质系统和着色器管理 * feat(material): 新增材质系统和着色器编辑器 * feat(tilemap): 增强tilemap编辑器和动画系统 * feat(modules): 添加module.json配置 * feat(core): 添加module.json和类型定义更新 * chore: 更新依赖和构建配置 * refactor(plugins): 更新插件模板使用ModuleManifest * chore: 添加第三方依赖库 * chore: 移除BehaviourTree-ai和ecs-astar子模块 * docs: 更新README和文档主题样式 * fix: 修复Rust文档测试和添加rapier2d WASM绑定 * fix(tilemap-editor): 修复画布高DPI屏幕分辨率适配问题 * feat(ui): 添加UI屏幕适配系统(CanvasScaler/SafeArea) * fix(ecs-engine-bindgen): 添加缺失的ecs-framework-math依赖 * fix: 添加缺失的包依赖修复CI构建 * fix: 修复CodeQL检测到的代码问题 * fix: 修复构建错误和缺失依赖 * fix: 修复类型检查错误 * fix(material-system): 修复tsconfig配置支持TypeScript项目引用 * fix(editor-core): 修复Rollup构建配置添加tauri external * fix: 修复CodeQL检测到的代码问题 * fix: 修复CodeQL检测到的代码问题
This commit is contained in:
46
packages/shader-editor/package.json
Normal file
46
packages/shader-editor/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"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",
|
||||
"zustand": "^5.0.8",
|
||||
"rimraf": "^5.0.5",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"keywords": [
|
||||
"ecs",
|
||||
"shader",
|
||||
"editor",
|
||||
"glsl"
|
||||
],
|
||||
"author": "yhh",
|
||||
"license": "MIT"
|
||||
}
|
||||
449
packages/shader-editor/src/analysis/ShaderAnalyzer.ts
Normal file
449
packages/shader-editor/src/analysis/ShaderAnalyzer.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* Shader Analyzer.
|
||||
* 着色器分析器。
|
||||
*
|
||||
* Parses GLSL shader code and extracts information about uniforms, attributes, varyings, and complexity.
|
||||
* 解析 GLSL 着色器代码并提取 uniforms、attributes、varyings 和复杂度信息。
|
||||
*/
|
||||
|
||||
/**
|
||||
* Uniform variable info.
|
||||
* Uniform 变量信息。
|
||||
*/
|
||||
export interface UniformInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
arraySize?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribute variable info.
|
||||
* Attribute 变量信息。
|
||||
*/
|
||||
export interface AttributeInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
location?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Varying variable info.
|
||||
* Varying 变量信息。
|
||||
*/
|
||||
export interface VaryingInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
qualifier: 'in' | 'out';
|
||||
}
|
||||
|
||||
/**
|
||||
* Shader complexity metrics.
|
||||
* 着色器复杂度指标。
|
||||
*/
|
||||
export interface ShaderComplexity {
|
||||
/** Total instruction count estimate. | 估计的总指令数。 */
|
||||
instructionCount: number;
|
||||
/** Texture sample count. | 纹理采样数。 */
|
||||
textureSamples: number;
|
||||
/** Branch count (if/else). | 分支数(if/else)。 */
|
||||
branches: number;
|
||||
/** Loop count. | 循环数。 */
|
||||
loops: number;
|
||||
/** Math operation count. | 数学运算数。 */
|
||||
mathOps: number;
|
||||
/** Complexity level. | 复杂度等级。 */
|
||||
level: 'low' | 'medium' | 'high' | 'very-high';
|
||||
/** Performance tips. | 性能建议。 */
|
||||
tips: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shader analysis result.
|
||||
* 着色器分析结果。
|
||||
*/
|
||||
export interface ShaderAnalysis {
|
||||
/** GLSL version. | GLSL 版本。 */
|
||||
version: string;
|
||||
/** Precision. | 精度。 */
|
||||
precision: string;
|
||||
/** Uniforms. | 统一变量。 */
|
||||
uniforms: UniformInfo[];
|
||||
/** Attributes (vertex shader). | 属性(顶点着色器)。 */
|
||||
attributes: AttributeInfo[];
|
||||
/** Varyings (in/out). | 可变量(输入/输出)。 */
|
||||
varyings: VaryingInfo[];
|
||||
/** Complexity metrics. | 复杂度指标。 */
|
||||
complexity: ShaderComplexity;
|
||||
/** Syntax errors. | 语法错误。 */
|
||||
errors: string[];
|
||||
/** Warnings. | 警告。 */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* GLSL type to size mapping.
|
||||
* GLSL 类型到大小的映射。
|
||||
*/
|
||||
const TYPE_SIZES: Record<string, number> = {
|
||||
'float': 1,
|
||||
'int': 1,
|
||||
'uint': 1,
|
||||
'bool': 1,
|
||||
'vec2': 2,
|
||||
'vec3': 3,
|
||||
'vec4': 4,
|
||||
'ivec2': 2,
|
||||
'ivec3': 3,
|
||||
'ivec4': 4,
|
||||
'uvec2': 2,
|
||||
'uvec3': 3,
|
||||
'uvec4': 4,
|
||||
'bvec2': 2,
|
||||
'bvec3': 3,
|
||||
'bvec4': 4,
|
||||
'mat2': 4,
|
||||
'mat3': 9,
|
||||
'mat4': 16,
|
||||
'sampler2D': 1,
|
||||
'samplerCube': 1,
|
||||
'sampler3D': 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* Shader Analyzer class.
|
||||
* 着色器分析器类。
|
||||
*/
|
||||
export class ShaderAnalyzer {
|
||||
/**
|
||||
* Analyze shader source code.
|
||||
* 分析着色器源代码。
|
||||
*/
|
||||
analyze(source: string, isVertex: boolean = false): ShaderAnalysis {
|
||||
const result: ShaderAnalysis = {
|
||||
version: '',
|
||||
precision: '',
|
||||
uniforms: [],
|
||||
attributes: [],
|
||||
varyings: [],
|
||||
complexity: {
|
||||
instructionCount: 0,
|
||||
textureSamples: 0,
|
||||
branches: 0,
|
||||
loops: 0,
|
||||
mathOps: 0,
|
||||
level: 'low',
|
||||
tips: []
|
||||
},
|
||||
errors: [],
|
||||
warnings: []
|
||||
};
|
||||
|
||||
try {
|
||||
// Remove comments for analysis.
|
||||
// 移除注释用于分析。
|
||||
const cleanSource = this.removeComments(source);
|
||||
|
||||
// Parse version.
|
||||
// 解析版本。
|
||||
result.version = this.parseVersion(cleanSource);
|
||||
|
||||
// Parse precision.
|
||||
// 解析精度。
|
||||
result.precision = this.parsePrecision(cleanSource);
|
||||
|
||||
// Parse uniforms.
|
||||
// 解析 uniforms。
|
||||
result.uniforms = this.parseUniforms(cleanSource);
|
||||
|
||||
// Parse attributes (vertex shader only).
|
||||
// 解析属性(仅顶点着色器)。
|
||||
if (isVertex) {
|
||||
result.attributes = this.parseAttributes(cleanSource);
|
||||
}
|
||||
|
||||
// Parse varyings (in/out).
|
||||
// 解析可变量(输入/输出)。
|
||||
result.varyings = this.parseVaryings(cleanSource, isVertex);
|
||||
|
||||
// Analyze complexity.
|
||||
// 分析复杂度。
|
||||
result.complexity = this.analyzeComplexity(cleanSource);
|
||||
|
||||
// Check for common issues.
|
||||
// 检查常见问题。
|
||||
this.checkWarnings(cleanSource, result);
|
||||
|
||||
} catch (error) {
|
||||
result.errors.push(`Analysis error: ${error}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove comments from source.
|
||||
* 从源代码中移除注释。
|
||||
*/
|
||||
private removeComments(source: string): string {
|
||||
// Remove single-line comments (non-greedy, stop at newline).
|
||||
// 移除单行注释(非贪婪,遇到换行停止)。
|
||||
let result = source.replace(/\/\/[^\n\r]*/g, '');
|
||||
// Remove multi-line comments.
|
||||
// 移除多行注释。
|
||||
result = result.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse GLSL version.
|
||||
* 解析 GLSL 版本。
|
||||
*/
|
||||
private parseVersion(source: string): string {
|
||||
const match = source.match(/#version\s+(\d+\s*\w*)/);
|
||||
return match ? match[1].trim() : 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse precision qualifier.
|
||||
* 解析精度限定符。
|
||||
*/
|
||||
private parsePrecision(source: string): string {
|
||||
const match = source.match(/precision\s+(lowp|mediump|highp)\s+float/);
|
||||
return match ? match[1] : 'not specified';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse uniform declarations.
|
||||
* 解析 uniform 声明。
|
||||
*/
|
||||
private parseUniforms(source: string): UniformInfo[] {
|
||||
const uniforms: UniformInfo[] = [];
|
||||
// Match: uniform type name; or uniform type name[size];
|
||||
const regex = /uniform\s+(\w+)\s+(\w+)(?:\[(\d+)\])?;/g;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(source)) !== null) {
|
||||
const info: UniformInfo = {
|
||||
name: match[2],
|
||||
type: match[1]
|
||||
};
|
||||
if (match[3]) {
|
||||
info.arraySize = parseInt(match[3], 10);
|
||||
}
|
||||
uniforms.push(info);
|
||||
}
|
||||
|
||||
return uniforms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse attribute declarations.
|
||||
* 解析 attribute 声明。
|
||||
*/
|
||||
private parseAttributes(source: string): AttributeInfo[] {
|
||||
const attributes: AttributeInfo[] = [];
|
||||
|
||||
// GLSL 300 es style: layout(location = n) in type name;
|
||||
const layoutRegex = /layout\s*\(\s*location\s*=\s*(\d+)\s*\)\s*in\s+(\w+)\s+(\w+);/g;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = layoutRegex.exec(source)) !== null) {
|
||||
attributes.push({
|
||||
name: match[3],
|
||||
type: match[2],
|
||||
location: parseInt(match[1], 10)
|
||||
});
|
||||
}
|
||||
|
||||
// Old style: attribute type name; or in type name;
|
||||
const attrRegex = /(?:attribute|in)\s+(\w+)\s+(\w+);/g;
|
||||
let attrMatch: RegExpExecArray | null;
|
||||
while ((attrMatch = attrRegex.exec(source)) !== null) {
|
||||
// Skip if already added via layout.
|
||||
if (!attributes.find(a => a.name === attrMatch![2])) {
|
||||
attributes.push({
|
||||
name: attrMatch[2],
|
||||
type: attrMatch[1]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse varying declarations (in/out).
|
||||
* 解析可变量声明(输入/输出)。
|
||||
*/
|
||||
private parseVaryings(source: string, isVertex: boolean): VaryingInfo[] {
|
||||
const varyings: VaryingInfo[] = [];
|
||||
|
||||
// Parse 'out' declarations.
|
||||
// 解析 'out' 声明。
|
||||
const outRegex = /(?<!layout\s*\([^)]*\)\s*)out\s+(\w+)\s+(\w+);/g;
|
||||
let match;
|
||||
|
||||
while ((match = outRegex.exec(source)) !== null) {
|
||||
varyings.push({
|
||||
name: match[2],
|
||||
type: match[1],
|
||||
qualifier: 'out'
|
||||
});
|
||||
}
|
||||
|
||||
// Parse 'in' declarations (skip attributes in vertex shader).
|
||||
// 解析 'in' 声明(跳过顶点着色器中的属性)。
|
||||
if (!isVertex) {
|
||||
const inRegex = /(?<!layout\s*\([^)]*\)\s*)in\s+(\w+)\s+(\w+);/g;
|
||||
while ((match = inRegex.exec(source)) !== null) {
|
||||
varyings.push({
|
||||
name: match[2],
|
||||
type: match[1],
|
||||
qualifier: 'in'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return varyings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze shader complexity.
|
||||
* 分析着色器复杂度。
|
||||
*/
|
||||
private analyzeComplexity(source: string): ShaderComplexity {
|
||||
const complexity: ShaderComplexity = {
|
||||
instructionCount: 0,
|
||||
textureSamples: 0,
|
||||
branches: 0,
|
||||
loops: 0,
|
||||
mathOps: 0,
|
||||
level: 'low',
|
||||
tips: []
|
||||
};
|
||||
|
||||
// Count texture samples.
|
||||
// 统计纹理采样。
|
||||
const textureCalls = source.match(/texture\s*\(/g) || [];
|
||||
complexity.textureSamples = textureCalls.length;
|
||||
|
||||
// Count branches.
|
||||
// 统计分支。
|
||||
const ifStatements = source.match(/\bif\s*\(/g) || [];
|
||||
const ternary = source.match(/\?/g) || [];
|
||||
complexity.branches = ifStatements.length + ternary.length;
|
||||
|
||||
// Count loops.
|
||||
// 统计循环。
|
||||
const forLoops = source.match(/\bfor\s*\(/g) || [];
|
||||
const whileLoops = source.match(/\bwhile\s*\(/g) || [];
|
||||
complexity.loops = forLoops.length + whileLoops.length;
|
||||
|
||||
// Count math operations.
|
||||
// 统计数学运算。
|
||||
const mathFuncs = [
|
||||
'sin', 'cos', 'tan', 'asin', 'acos', 'atan',
|
||||
'pow', 'exp', 'log', 'sqrt', 'inversesqrt',
|
||||
'abs', 'floor', 'ceil', 'fract', 'mod',
|
||||
'min', 'max', 'clamp', 'mix', 'step', 'smoothstep',
|
||||
'length', 'distance', 'dot', 'cross', 'normalize', 'reflect', 'refract'
|
||||
];
|
||||
for (const func of mathFuncs) {
|
||||
const matches = source.match(new RegExp(`\\b${func}\\s*\\(`, 'g')) || [];
|
||||
complexity.mathOps += matches.length;
|
||||
}
|
||||
|
||||
// Estimate instruction count.
|
||||
// 估计指令数。
|
||||
const lines = source.split('\n').filter(l => l.trim() && !l.trim().startsWith('//'));
|
||||
complexity.instructionCount = lines.length +
|
||||
complexity.textureSamples * 4 +
|
||||
complexity.mathOps * 2 +
|
||||
complexity.branches * 2;
|
||||
|
||||
// Determine complexity level.
|
||||
// 确定复杂度等级。
|
||||
if (complexity.instructionCount > 200 || complexity.textureSamples > 8 || complexity.loops > 3) {
|
||||
complexity.level = 'very-high';
|
||||
} else if (complexity.instructionCount > 100 || complexity.textureSamples > 4 || complexity.loops > 1) {
|
||||
complexity.level = 'high';
|
||||
} else if (complexity.instructionCount > 50 || complexity.textureSamples > 2) {
|
||||
complexity.level = 'medium';
|
||||
} else {
|
||||
complexity.level = 'low';
|
||||
}
|
||||
|
||||
// Generate tips.
|
||||
// 生成建议。
|
||||
if (complexity.textureSamples > 4) {
|
||||
complexity.tips.push('Consider reducing texture samples for better performance.');
|
||||
}
|
||||
if (complexity.loops > 2) {
|
||||
complexity.tips.push('Nested loops can significantly impact performance.');
|
||||
}
|
||||
if (complexity.branches > 5) {
|
||||
complexity.tips.push('Many branches can cause performance issues on some GPUs.');
|
||||
}
|
||||
if (source.includes('discard')) {
|
||||
complexity.tips.push('Using discard can prevent early-z optimization.');
|
||||
}
|
||||
|
||||
return complexity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for common issues and warnings.
|
||||
* 检查常见问题和警告。
|
||||
*/
|
||||
private checkWarnings(source: string, result: ShaderAnalysis): void {
|
||||
// Check for missing precision.
|
||||
// 检查缺少精度。
|
||||
if (result.precision === 'not specified') {
|
||||
result.warnings.push('No precision qualifier specified. Consider adding "precision highp float;"');
|
||||
}
|
||||
|
||||
// Check for unused uniforms.
|
||||
// 检查未使用的 uniforms。
|
||||
for (const uniform of result.uniforms) {
|
||||
const usageRegex = new RegExp(`\\b${uniform.name}\\b`, 'g');
|
||||
const matches = source.match(usageRegex) || [];
|
||||
if (matches.length <= 1) { // Only the declaration.
|
||||
result.warnings.push(`Uniform "${uniform.name}" may be unused.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for expensive operations in loops.
|
||||
// 检查循环中的昂贵操作。
|
||||
if (result.complexity.loops > 0 && result.complexity.textureSamples > 0) {
|
||||
// Simple heuristic: if we have loops and texture samples, warn.
|
||||
result.warnings.push('Texture sampling in loops can be expensive.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get type size in floats.
|
||||
* 获取类型大小(以 float 为单位)。
|
||||
*/
|
||||
getTypeSize(type: string): number {
|
||||
return TYPE_SIZES[type] || 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total uniform buffer size.
|
||||
* 计算 uniform 缓冲区总大小。
|
||||
*/
|
||||
calculateUniformBufferSize(uniforms: UniformInfo[]): number {
|
||||
let size = 0;
|
||||
for (const uniform of uniforms) {
|
||||
const typeSize = this.getTypeSize(uniform.type);
|
||||
const count = uniform.arraySize || 1;
|
||||
size += typeSize * count;
|
||||
}
|
||||
return size;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance.
|
||||
// 导出单例实例。
|
||||
export const shaderAnalyzer = new ShaderAnalyzer();
|
||||
396
packages/shader-editor/src/components/ShaderEditorPanel.css
Normal file
396
packages/shader-editor/src/components/ShaderEditorPanel.css
Normal file
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* Shader Editor Panel Styles.
|
||||
* 着色器编辑器面板样式。
|
||||
*/
|
||||
|
||||
.shader-editor-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-primary, #1e1e1e);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.shader-editor-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary, #888);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.shader-editor-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary, #666);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.shader-editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
background: var(--bg-secondary, #252526);
|
||||
}
|
||||
|
||||
.shader-editor-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.shader-editor-dirty {
|
||||
color: var(--warning-color, #fbbf24);
|
||||
}
|
||||
|
||||
.shader-editor-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.shader-editor-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 4px;
|
||||
background: var(--button-bg, #333);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.shader-editor-btn:hover:not(:disabled) {
|
||||
background: var(--button-hover-bg, #444);
|
||||
border-color: var(--accent-color, #0078d4);
|
||||
}
|
||||
|
||||
.shader-editor-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Status bar */
|
||||
.shader-editor-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.shader-editor-status.error {
|
||||
background: var(--error-bg, #3a2020);
|
||||
color: var(--error-color, #f87171);
|
||||
}
|
||||
|
||||
.shader-editor-status.success {
|
||||
background: var(--success-bg, #1a3a1a);
|
||||
color: var(--success-color, #4ade80);
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
.shader-editor-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Code section */
|
||||
.shader-editor-code-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border-color, #333);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.shader-editor-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
background: var(--bg-secondary, #252526);
|
||||
}
|
||||
|
||||
.shader-editor-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-secondary, #888);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.shader-editor-tab:hover {
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.shader-editor-tab.active {
|
||||
color: var(--accent-color, #0078d4);
|
||||
border-bottom-color: var(--accent-color, #0078d4);
|
||||
}
|
||||
|
||||
/* Textarea wrapper */
|
||||
.shader-editor-textarea-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shader-editor-line-numbers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 8px;
|
||||
background: var(--bg-tertiary, #1a1a1a);
|
||||
color: var(--text-tertiary, #666);
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
border-right: 1px solid var(--border-color, #333);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.shader-editor-line-numbers span {
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.shader-editor-textarea {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
background: var(--bg-primary, #1e1e1e);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
outline: none;
|
||||
tab-size: 4;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.shader-editor-textarea::placeholder {
|
||||
color: var(--text-tertiary, #555);
|
||||
}
|
||||
|
||||
/* Analysis section */
|
||||
.shader-editor-analysis-section {
|
||||
width: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary, #252526);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shader-editor-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.shader-editor-section-header:hover {
|
||||
background: var(--bg-hover, #2a2a2a);
|
||||
}
|
||||
|
||||
.shader-editor-analysis-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* Analysis sections */
|
||||
.analysis-section {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-tertiary, #1e1e1e);
|
||||
}
|
||||
|
||||
.analysis-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #888);
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Complexity badge */
|
||||
.complexity-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.complexity-badge.low {
|
||||
background: var(--success-bg, #1a3a1a);
|
||||
color: var(--success-color, #4ade80);
|
||||
}
|
||||
|
||||
.complexity-badge.medium {
|
||||
background: var(--info-bg, #1a2a3a);
|
||||
color: var(--info-color, #60a5fa);
|
||||
}
|
||||
|
||||
.complexity-badge.high {
|
||||
background: var(--warning-bg, #3a3a1a);
|
||||
color: var(--warning-color, #fbbf24);
|
||||
}
|
||||
|
||||
.complexity-badge.very-high {
|
||||
background: var(--error-bg, #3a2020);
|
||||
color: var(--error-color, #f87171);
|
||||
}
|
||||
|
||||
/* Metrics */
|
||||
.analysis-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Analysis list */
|
||||
.analysis-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.analysis-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-primary, #1e1e1e);
|
||||
font-size: 11px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.item-type {
|
||||
color: var(--accent-color, #0078d4);
|
||||
}
|
||||
|
||||
.item-name {
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.item-array {
|
||||
color: var(--text-tertiary, #666);
|
||||
}
|
||||
|
||||
.item-location {
|
||||
color: var(--text-tertiary, #666);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.item-qualifier {
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.item-qualifier.in {
|
||||
background: var(--success-bg, #1a3a1a);
|
||||
color: var(--success-color, #4ade80);
|
||||
}
|
||||
|
||||
.item-qualifier.out {
|
||||
background: var(--info-bg, #1a2a3a);
|
||||
color: var(--info-color, #60a5fa);
|
||||
}
|
||||
|
||||
/* Tips and warnings */
|
||||
.analysis-tips,
|
||||
.analysis-warnings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.analysis-tip,
|
||||
.analysis-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.analysis-tip {
|
||||
background: var(--warning-bg, #3a3a1a);
|
||||
color: var(--warning-color, #fbbf24);
|
||||
}
|
||||
|
||||
.analysis-warning {
|
||||
background: var(--warning-bg, #3a3a1a);
|
||||
color: var(--warning-color, #fbbf24);
|
||||
}
|
||||
|
||||
.analysis-tip svg,
|
||||
.analysis-warning svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Info */
|
||||
.analysis-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
430
packages/shader-editor/src/components/ShaderEditorPanel.tsx
Normal file
430
packages/shader-editor/src/components/ShaderEditorPanel.tsx
Normal file
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* Shader Editor Panel.
|
||||
* 着色器编辑器面板。
|
||||
*
|
||||
* Provides shader code editing, analysis, and preview.
|
||||
* 提供着色器代码编辑、分析和预览功能。
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { MessageHub, IFileSystemService, IFileSystem, ProjectService } from '@esengine/editor-core';
|
||||
import { getMaterialManager, Shader } from '@esengine/material-system';
|
||||
import {
|
||||
Save, RefreshCw, Play, AlertTriangle, CheckCircle,
|
||||
Code, Eye, BarChart3, FileCode, ChevronDown, ChevronRight
|
||||
} from 'lucide-react';
|
||||
import { ShaderAnalyzer, ShaderAnalysis } from '../analysis/ShaderAnalyzer';
|
||||
import { useShaderEditorStore } from '../stores/ShaderEditorStore';
|
||||
import './ShaderEditorPanel.css';
|
||||
|
||||
/**
|
||||
* Shader Editor Panel Props.
|
||||
* 着色器编辑器面板属性。
|
||||
*/
|
||||
interface ShaderEditorPanelProps {
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shader Editor Panel Component.
|
||||
* 着色器编辑器面板组件。
|
||||
*/
|
||||
export function ShaderEditorPanel({ filePath: propFilePath }: ShaderEditorPanelProps) {
|
||||
const {
|
||||
filePath, shaderData, isDirty,
|
||||
setFilePath, setShaderData, setDirty, reset
|
||||
} = useShaderEditorStore();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'vertex' | 'fragment'>('fragment');
|
||||
const [showAnalysis, setShowAnalysis] = useState(true);
|
||||
const [_showPreview, _setShowPreview] = useState(true);
|
||||
const [vertexAnalysis, setVertexAnalysis] = useState<ShaderAnalysis | null>(null);
|
||||
const [fragmentAnalysis, setFragmentAnalysis] = useState<ShaderAnalysis | null>(null);
|
||||
const [compileError, setCompileError] = useState<string | null>(null);
|
||||
const [compileSuccess, setCompileSuccess] = useState(false);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const analyzer = useRef(new ShaderAnalyzer());
|
||||
|
||||
// Load shader file.
|
||||
// 加载着色器文件。
|
||||
useEffect(() => {
|
||||
const pathToLoad = propFilePath || filePath;
|
||||
if (pathToLoad) {
|
||||
loadShaderFile(pathToLoad);
|
||||
}
|
||||
}, [propFilePath]);
|
||||
|
||||
// Subscribe to file open messages.
|
||||
// 订阅文件打开消息。
|
||||
useEffect(() => {
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (!messageHub) return;
|
||||
|
||||
const unsubscribe = messageHub.subscribe('shader:open', (payload: { filePath: string }) => {
|
||||
loadShaderFile(payload.filePath);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
// Analyze shader when source changes.
|
||||
// 当源代码改变时分析着色器。
|
||||
useEffect(() => {
|
||||
if (shaderData) {
|
||||
setVertexAnalysis(analyzer.current.analyze(shaderData.vertex, true));
|
||||
setFragmentAnalysis(analyzer.current.analyze(shaderData.fragment, false));
|
||||
}
|
||||
}, [shaderData?.vertex, shaderData?.fragment]);
|
||||
|
||||
const loadShaderFile = async (path: string) => {
|
||||
const fileSystem = Core.services.tryResolve<IFileSystem>(IFileSystemService);
|
||||
if (!fileSystem) return;
|
||||
|
||||
try {
|
||||
const content = await fileSystem.readFile(path);
|
||||
const data = JSON.parse(content);
|
||||
setFilePath(path);
|
||||
setShaderData({
|
||||
version: data.version || 1,
|
||||
name: data.shader.name || 'Untitled',
|
||||
vertex: data.shader.vertexSource || '',
|
||||
fragment: data.shader.fragmentSource || ''
|
||||
});
|
||||
setDirty(false);
|
||||
setCompileError(null);
|
||||
setCompileSuccess(false);
|
||||
} catch (error) {
|
||||
console.error('[ShaderEditorPanel] Failed to load shader:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!filePath || !shaderData) return;
|
||||
|
||||
const fileSystem = Core.services.tryResolve<IFileSystem>(IFileSystemService);
|
||||
if (!fileSystem) return;
|
||||
|
||||
try {
|
||||
// Save in new wrapper format.
|
||||
// 以新的包装格式保存。
|
||||
const fileData = {
|
||||
version: shaderData.version || 1,
|
||||
shader: {
|
||||
name: shaderData.name,
|
||||
vertexSource: shaderData.vertex,
|
||||
fragmentSource: shaderData.fragment
|
||||
}
|
||||
};
|
||||
const content = JSON.stringify(fileData, null, 2);
|
||||
await fileSystem.writeFile(filePath, content);
|
||||
setDirty(false);
|
||||
|
||||
// Notify.
|
||||
const messageHub = Core.services.tryResolve(MessageHub);
|
||||
if (messageHub) {
|
||||
messageHub.publish('shader:saved', { filePath });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ShaderEditorPanel] Failed to save shader:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompile = async () => {
|
||||
if (!shaderData) return;
|
||||
|
||||
setCompileError(null);
|
||||
setCompileSuccess(false);
|
||||
|
||||
try {
|
||||
const materialManager = getMaterialManager();
|
||||
if (!materialManager) {
|
||||
setCompileError('MaterialManager not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a temporary shader to test compilation.
|
||||
// 创建临时着色器测试编译。
|
||||
const testShader = new Shader(
|
||||
`test_${Date.now()}`,
|
||||
shaderData.vertex,
|
||||
shaderData.fragment
|
||||
);
|
||||
|
||||
// Try to register (which compiles).
|
||||
// 尝试注册(会进行编译)。
|
||||
const shaderId = await materialManager.registerShader(testShader);
|
||||
|
||||
if (shaderId > 0) {
|
||||
setCompileSuccess(true);
|
||||
// Remove test shader.
|
||||
materialManager.removeShader(shaderId);
|
||||
} else {
|
||||
setCompileError('Compilation failed');
|
||||
}
|
||||
} catch (error: any) {
|
||||
setCompileError(error.message || 'Compilation failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSourceChange = (type: 'vertex' | 'fragment', value: string) => {
|
||||
if (!shaderData) return;
|
||||
|
||||
setShaderData({
|
||||
...shaderData,
|
||||
[type]: value
|
||||
});
|
||||
setDirty(true);
|
||||
setCompileSuccess(false);
|
||||
};
|
||||
|
||||
const currentAnalysis = activeTab === 'vertex' ? vertexAnalysis : fragmentAnalysis;
|
||||
|
||||
if (!shaderData) {
|
||||
return (
|
||||
<div className="shader-editor-panel shader-editor-empty">
|
||||
<FileCode size={48} />
|
||||
<p>No shader loaded</p>
|
||||
<p className="shader-editor-hint">Open a .shader file to edit</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shader-editor-panel">
|
||||
{/* Header */}
|
||||
<div className="shader-editor-header">
|
||||
<div className="shader-editor-title">
|
||||
<FileCode size={16} />
|
||||
<span>{shaderData.name}</span>
|
||||
{isDirty && <span className="shader-editor-dirty">*</span>}
|
||||
</div>
|
||||
<div className="shader-editor-actions">
|
||||
<button
|
||||
className="shader-editor-btn"
|
||||
onClick={handleCompile}
|
||||
title="Compile shader"
|
||||
>
|
||||
<Play size={14} />
|
||||
Compile
|
||||
</button>
|
||||
<button
|
||||
className="shader-editor-btn"
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty}
|
||||
title="Save shader"
|
||||
>
|
||||
<Save size={14} />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compile status */}
|
||||
{compileError && (
|
||||
<div className="shader-editor-status error">
|
||||
<AlertTriangle size={14} />
|
||||
<span>{compileError}</span>
|
||||
</div>
|
||||
)}
|
||||
{compileSuccess && (
|
||||
<div className="shader-editor-status success">
|
||||
<CheckCircle size={14} />
|
||||
<span>Compilation successful!</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="shader-editor-content">
|
||||
{/* Code editor */}
|
||||
<div className="shader-editor-code-section">
|
||||
{/* Tabs */}
|
||||
<div className="shader-editor-tabs">
|
||||
<button
|
||||
className={`shader-editor-tab ${activeTab === 'vertex' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('vertex')}
|
||||
>
|
||||
<Code size={12} />
|
||||
Vertex
|
||||
</button>
|
||||
<button
|
||||
className={`shader-editor-tab ${activeTab === 'fragment' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('fragment')}
|
||||
>
|
||||
<Code size={12} />
|
||||
Fragment
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div className="shader-editor-textarea-wrapper">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="shader-editor-textarea"
|
||||
value={activeTab === 'vertex' ? shaderData.vertex : shaderData.fragment}
|
||||
onChange={e => handleSourceChange(activeTab, e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder={`Enter ${activeTab} shader code...`}
|
||||
/>
|
||||
<div className="shader-editor-line-numbers">
|
||||
{(activeTab === 'vertex' ? shaderData.vertex : shaderData.fragment)
|
||||
.split('\n')
|
||||
.map((_, i) => (
|
||||
<span key={i}>{i + 1}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analysis panel */}
|
||||
<div className="shader-editor-analysis-section">
|
||||
{/* Analysis header */}
|
||||
<div
|
||||
className="shader-editor-section-header"
|
||||
onClick={() => setShowAnalysis(!showAnalysis)}
|
||||
>
|
||||
{showAnalysis ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<BarChart3 size={14} />
|
||||
<span>Analysis</span>
|
||||
</div>
|
||||
|
||||
{showAnalysis && currentAnalysis && (
|
||||
<div className="shader-editor-analysis-content">
|
||||
{/* Complexity */}
|
||||
<div className="analysis-section">
|
||||
<div className="analysis-section-title">Complexity</div>
|
||||
<div className={`complexity-badge ${currentAnalysis.complexity.level}`}>
|
||||
{currentAnalysis.complexity.level.toUpperCase()}
|
||||
</div>
|
||||
<div className="analysis-metrics">
|
||||
<div className="metric">
|
||||
<span className="metric-label">Instructions</span>
|
||||
<span className="metric-value">~{currentAnalysis.complexity.instructionCount}</span>
|
||||
</div>
|
||||
<div className="metric">
|
||||
<span className="metric-label">Texture Samples</span>
|
||||
<span className="metric-value">{currentAnalysis.complexity.textureSamples}</span>
|
||||
</div>
|
||||
<div className="metric">
|
||||
<span className="metric-label">Branches</span>
|
||||
<span className="metric-value">{currentAnalysis.complexity.branches}</span>
|
||||
</div>
|
||||
<div className="metric">
|
||||
<span className="metric-label">Loops</span>
|
||||
<span className="metric-value">{currentAnalysis.complexity.loops}</span>
|
||||
</div>
|
||||
<div className="metric">
|
||||
<span className="metric-label">Math Ops</span>
|
||||
<span className="metric-value">{currentAnalysis.complexity.mathOps}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Uniforms */}
|
||||
{currentAnalysis.uniforms.length > 0 && (
|
||||
<div className="analysis-section">
|
||||
<div className="analysis-section-title">
|
||||
Uniforms ({currentAnalysis.uniforms.length})
|
||||
</div>
|
||||
<div className="analysis-list">
|
||||
{currentAnalysis.uniforms.map((u, i) => (
|
||||
<div key={i} className="analysis-item">
|
||||
<span className="item-type">{u.type}</span>
|
||||
<span className="item-name">{u.name}</span>
|
||||
{u.arraySize && <span className="item-array">[{u.arraySize}]</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attributes (vertex only) */}
|
||||
{activeTab === 'vertex' && currentAnalysis.attributes.length > 0 && (
|
||||
<div className="analysis-section">
|
||||
<div className="analysis-section-title">
|
||||
Attributes ({currentAnalysis.attributes.length})
|
||||
</div>
|
||||
<div className="analysis-list">
|
||||
{currentAnalysis.attributes.map((a, i) => (
|
||||
<div key={i} className="analysis-item">
|
||||
<span className="item-type">{a.type}</span>
|
||||
<span className="item-name">{a.name}</span>
|
||||
{a.location !== undefined && (
|
||||
<span className="item-location">loc={a.location}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Varyings */}
|
||||
{currentAnalysis.varyings.length > 0 && (
|
||||
<div className="analysis-section">
|
||||
<div className="analysis-section-title">
|
||||
Varyings ({currentAnalysis.varyings.length})
|
||||
</div>
|
||||
<div className="analysis-list">
|
||||
{currentAnalysis.varyings.map((v, i) => (
|
||||
<div key={i} className="analysis-item">
|
||||
<span className={`item-qualifier ${v.qualifier}`}>{v.qualifier}</span>
|
||||
<span className="item-type">{v.type}</span>
|
||||
<span className="item-name">{v.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tips */}
|
||||
{currentAnalysis.complexity.tips.length > 0 && (
|
||||
<div className="analysis-section">
|
||||
<div className="analysis-section-title">Performance Tips</div>
|
||||
<div className="analysis-tips">
|
||||
{currentAnalysis.complexity.tips.map((tip, i) => (
|
||||
<div key={i} className="analysis-tip">
|
||||
<AlertTriangle size={12} />
|
||||
<span>{tip}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{currentAnalysis.warnings.length > 0 && (
|
||||
<div className="analysis-section">
|
||||
<div className="analysis-section-title">Warnings</div>
|
||||
<div className="analysis-warnings">
|
||||
{currentAnalysis.warnings.map((warning, i) => (
|
||||
<div key={i} className="analysis-warning">
|
||||
<AlertTriangle size={12} />
|
||||
<span>{warning}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="analysis-section">
|
||||
<div className="analysis-section-title">Info</div>
|
||||
<div className="analysis-info">
|
||||
<div className="info-item">
|
||||
<span className="info-label">GLSL Version</span>
|
||||
<span className="info-value">{currentAnalysis.version}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="info-label">Precision</span>
|
||||
<span className="info-value">{currentAnalysis.precision}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
packages/shader-editor/src/index.ts
Normal file
110
packages/shader-editor/src/index.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @esengine/shader-editor
|
||||
*
|
||||
* Shader editor with code editing, analysis, and preview.
|
||||
* 着色器编辑器,支持代码编辑、分析和预览。
|
||||
*/
|
||||
|
||||
import type { ServiceContainer } from '@esengine/ecs-framework';
|
||||
import type {
|
||||
IEditorModuleLoader,
|
||||
IPlugin,
|
||||
ModuleManifest,
|
||||
IFileSystem
|
||||
} from '@esengine/editor-core';
|
||||
import {
|
||||
InspectorRegistry,
|
||||
IInspectorRegistry,
|
||||
IFileSystemService
|
||||
} from '@esengine/editor-core';
|
||||
|
||||
// Components
|
||||
import { useShaderEditorStore } from './stores/ShaderEditorStore';
|
||||
import { ShaderAssetInspectorProvider } from './providers/ShaderAssetInspectorProvider';
|
||||
|
||||
// Import styles
|
||||
import './styles/ShaderInspector.css';
|
||||
|
||||
// Re-exports
|
||||
export { useShaderEditorStore, createDefaultShaderData } from './stores/ShaderEditorStore';
|
||||
export type { ShaderData, ShaderEditorState } from './stores/ShaderEditorStore';
|
||||
export { ShaderAnalyzer, shaderAnalyzer } from './analysis/ShaderAnalyzer';
|
||||
export type {
|
||||
ShaderAnalysis,
|
||||
ShaderComplexity,
|
||||
UniformInfo,
|
||||
AttributeInfo,
|
||||
VaryingInfo
|
||||
} from './analysis/ShaderAnalyzer';
|
||||
export { ShaderAssetInspectorProvider } from './providers/ShaderAssetInspectorProvider';
|
||||
|
||||
/**
|
||||
* Shader Editor Module.
|
||||
* 着色器编辑器模块。
|
||||
*/
|
||||
export class ShaderEditorModule implements IEditorModuleLoader {
|
||||
private unsubscribers: Array<() => void> = [];
|
||||
private inspectorProvider?: ShaderAssetInspectorProvider;
|
||||
|
||||
async install(services: ServiceContainer): Promise<void> {
|
||||
// Register Shader Asset Inspector Provider.
|
||||
// 注册着色器资产检视器提供者。
|
||||
const inspectorRegistry = services.resolve<InspectorRegistry>(IInspectorRegistry);
|
||||
if (inspectorRegistry) {
|
||||
this.inspectorProvider = new ShaderAssetInspectorProvider();
|
||||
|
||||
// Set up save handler.
|
||||
// 设置保存处理器。
|
||||
const fileSystem = services.tryResolve<IFileSystem>(IFileSystemService);
|
||||
if (fileSystem) {
|
||||
this.inspectorProvider.setSaveHandler(async (path, content) => {
|
||||
await fileSystem.writeFile(path, content);
|
||||
});
|
||||
}
|
||||
|
||||
inspectorRegistry.register(this.inspectorProvider);
|
||||
}
|
||||
}
|
||||
|
||||
async uninstall(): Promise<void> {
|
||||
// Clean up subscriptions.
|
||||
this.unsubscribers.forEach(unsub => unsub());
|
||||
this.unsubscribers = [];
|
||||
// Reset store.
|
||||
useShaderEditorStore.getState().reset();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const shaderEditorModule = new ShaderEditorModule();
|
||||
|
||||
/**
|
||||
* Shader Editor Plugin Manifest.
|
||||
* 着色器编辑器插件清单。
|
||||
*/
|
||||
const manifest: ModuleManifest = {
|
||||
id: '@esengine/shader-editor',
|
||||
name: '@esengine/shader-editor',
|
||||
displayName: 'Shader Editor',
|
||||
version: '1.0.0',
|
||||
description: 'Shader code editing with analysis and preview',
|
||||
category: 'Rendering',
|
||||
isCore: true,
|
||||
defaultEnabled: true,
|
||||
isEngineModule: true,
|
||||
dependencies: ['material-system'],
|
||||
exports: {
|
||||
other: ['ShaderAnalyzer']
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Shader Editor Plugin (editor only, no runtime).
|
||||
* 着色器编辑器插件(仅编辑器,无运行时)。
|
||||
*/
|
||||
export const ShaderEditorPlugin: IPlugin = {
|
||||
manifest,
|
||||
editorModule: shaderEditorModule
|
||||
};
|
||||
|
||||
export default shaderEditorModule;
|
||||
@@ -0,0 +1,514 @@
|
||||
/**
|
||||
* ShaderAssetInspectorProvider - Inspector provider for .shader files.
|
||||
* 着色器资产检视器提供者 - 用于 .shader 文件的检视器。
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import type { IInspectorProvider, InspectorContext } from '@esengine/editor-core';
|
||||
import {
|
||||
Save, RotateCcw, Play, AlertTriangle, CheckCircle,
|
||||
Code, ChevronDown, ChevronRight, BarChart3, FileCode
|
||||
} from 'lucide-react';
|
||||
import { ShaderAnalyzer, ShaderAnalysis } from '../analysis/ShaderAnalyzer';
|
||||
import '../styles/ShaderInspector.css';
|
||||
|
||||
/**
|
||||
* Asset file info interface.
|
||||
* 资产文件信息接口。
|
||||
*/
|
||||
interface AssetFileInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
extension?: string;
|
||||
size?: number;
|
||||
modified?: number;
|
||||
isDirectory: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asset file target with content.
|
||||
* 带内容的资产文件目标。
|
||||
*/
|
||||
interface AssetFileTarget {
|
||||
type: 'asset-file';
|
||||
data: AssetFileInfo;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shader data structure (internal format for editing).
|
||||
* 着色器数据结构(用于编辑的内部格式)。
|
||||
*/
|
||||
interface ShaderData {
|
||||
version?: number;
|
||||
name: string;
|
||||
vertex: string;
|
||||
fragment: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shader file format (wrapper format).
|
||||
* 着色器文件格式(包装格式)。
|
||||
*/
|
||||
interface ShaderFileFormat {
|
||||
version: number;
|
||||
shader: {
|
||||
name: string;
|
||||
vertexSource: string;
|
||||
fragmentSource: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ShaderInspectorViewProps {
|
||||
fileInfo: AssetFileInfo;
|
||||
content: string;
|
||||
onSave?: (path: string, content: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shader Inspector View Component.
|
||||
* 着色器检视器视图组件。
|
||||
*/
|
||||
function ShaderInspectorView({ fileInfo, content, onSave }: ShaderInspectorViewProps) {
|
||||
const [shader, setShader] = useState<ShaderData | null>(null);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'vertex' | 'fragment'>('fragment');
|
||||
const [vertexAnalysis, setVertexAnalysis] = useState<ShaderAnalysis | null>(null);
|
||||
const [fragmentAnalysis, setFragmentAnalysis] = useState<ShaderAnalysis | null>(null);
|
||||
const [analysisExpanded, setAnalysisExpanded] = useState(true);
|
||||
const [compileStatus, setCompileStatus] = useState<'none' | 'success' | 'error'>('none');
|
||||
const [compileError, setCompileError] = useState<string | null>(null);
|
||||
|
||||
const analyzer = useRef(new ShaderAnalyzer());
|
||||
|
||||
// Parse shader content.
|
||||
// 解析着色器内容。
|
||||
useEffect(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(content) as ShaderFileFormat;
|
||||
// Convert from file format to internal format.
|
||||
// 从文件格式转换为内部格式。
|
||||
setShader({
|
||||
version: parsed.version,
|
||||
name: parsed.shader.name,
|
||||
vertex: parsed.shader.vertexSource,
|
||||
fragment: parsed.shader.fragmentSource
|
||||
});
|
||||
setError(null);
|
||||
setIsDirty(false);
|
||||
setCompileStatus('none');
|
||||
} catch (e) {
|
||||
setError('Failed to parse shader file');
|
||||
setShader(null);
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
// Analyze shader when source changes.
|
||||
// 当源代码改变时分析着色器。
|
||||
useEffect(() => {
|
||||
if (shader) {
|
||||
setVertexAnalysis(analyzer.current.analyze(shader.vertex || '', true));
|
||||
setFragmentAnalysis(analyzer.current.analyze(shader.fragment || '', false));
|
||||
}
|
||||
}, [shader?.vertex, shader?.fragment]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!shader || !onSave) return;
|
||||
try {
|
||||
// Convert internal format back to file format.
|
||||
// 将内部格式转换回文件格式。
|
||||
const fileData: ShaderFileFormat = {
|
||||
version: shader.version || 1,
|
||||
shader: {
|
||||
name: shader.name,
|
||||
vertexSource: shader.vertex,
|
||||
fragmentSource: shader.fragment
|
||||
}
|
||||
};
|
||||
const jsonContent = JSON.stringify(fileData, null, 2);
|
||||
await onSave(fileInfo.path, jsonContent);
|
||||
setIsDirty(false);
|
||||
} catch (e) {
|
||||
console.error('[ShaderInspector] Failed to save:', e);
|
||||
}
|
||||
}, [shader, fileInfo.path, onSave]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(content) as ShaderFileFormat;
|
||||
setShader({
|
||||
version: parsed.version,
|
||||
name: parsed.shader.name,
|
||||
vertex: parsed.shader.vertexSource,
|
||||
fragment: parsed.shader.fragmentSource
|
||||
});
|
||||
setIsDirty(false);
|
||||
setCompileStatus('none');
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
const handleSourceChange = (type: 'vertex' | 'fragment', value: string) => {
|
||||
if (!shader) return;
|
||||
setShader({ ...shader, [type]: value });
|
||||
setIsDirty(true);
|
||||
setCompileStatus('none');
|
||||
};
|
||||
|
||||
const handleCompile = async () => {
|
||||
if (!shader) return;
|
||||
|
||||
setCompileStatus('none');
|
||||
setCompileError(null);
|
||||
|
||||
try {
|
||||
// Dynamic import to avoid circular dependencies.
|
||||
// 动态导入避免循环依赖。
|
||||
const { getMaterialManager, Shader } = await import('@esengine/material-system');
|
||||
const materialManager = getMaterialManager();
|
||||
|
||||
if (!materialManager) {
|
||||
setCompileError('MaterialManager not available');
|
||||
setCompileStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create test shader.
|
||||
// 创建测试着色器。
|
||||
const testShader = new Shader(
|
||||
`test_${Date.now()}`,
|
||||
shader.vertex,
|
||||
shader.fragment
|
||||
);
|
||||
|
||||
const shaderId = await materialManager.registerShader(testShader);
|
||||
if (shaderId > 0) {
|
||||
setCompileStatus('success');
|
||||
materialManager.removeShader(shaderId);
|
||||
} else {
|
||||
setCompileError('Compilation failed');
|
||||
setCompileStatus('error');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setCompileError(err.message || 'Compilation failed');
|
||||
setCompileStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
const currentAnalysis = activeTab === 'vertex' ? vertexAnalysis : fragmentAnalysis;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="entity-inspector shader-inspector">
|
||||
<div className="inspector-header">
|
||||
<FileCode size={16} style={{ color: '#60a5fa' }} />
|
||||
<span className="entity-name">{fileInfo.name}</span>
|
||||
</div>
|
||||
<div className="inspector-content">
|
||||
<div className="shader-error">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!shader) {
|
||||
return (
|
||||
<div className="entity-inspector shader-inspector">
|
||||
<div className="inspector-header">
|
||||
<FileCode size={16} style={{ color: '#60a5fa' }} />
|
||||
<span className="entity-name">{fileInfo.name}</span>
|
||||
</div>
|
||||
<div className="inspector-content">
|
||||
<div className="shader-loading">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="entity-inspector shader-inspector">
|
||||
{/* Header */}
|
||||
<div className="inspector-header">
|
||||
<FileCode size={16} style={{ color: '#60a5fa' }} />
|
||||
<span className="entity-name">{shader.name || fileInfo.name}</span>
|
||||
{isDirty && <span className="shader-dirty-indicator">*</span>}
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="shader-toolbar">
|
||||
<button
|
||||
className="shader-toolbar-btn"
|
||||
onClick={handleCompile}
|
||||
title="Compile shader"
|
||||
>
|
||||
<Play size={14} />
|
||||
<span>Compile</span>
|
||||
</button>
|
||||
<button
|
||||
className="shader-toolbar-btn"
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty || !onSave}
|
||||
title="Save"
|
||||
>
|
||||
<Save size={14} />
|
||||
<span>Save</span>
|
||||
</button>
|
||||
<button
|
||||
className="shader-toolbar-btn"
|
||||
onClick={handleReset}
|
||||
disabled={!isDirty}
|
||||
title="Reset"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
<span>Reset</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Compile Status */}
|
||||
{compileStatus === 'success' && (
|
||||
<div className="shader-status success">
|
||||
<CheckCircle size={14} />
|
||||
<span>Compilation successful!</span>
|
||||
</div>
|
||||
)}
|
||||
{compileStatus === 'error' && (
|
||||
<div className="shader-status error">
|
||||
<AlertTriangle size={14} />
|
||||
<span>{compileError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="inspector-content">
|
||||
{/* Basic Properties */}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Properties</div>
|
||||
<div className="property-field">
|
||||
<label className="property-label">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="shader-input"
|
||||
value={shader.name}
|
||||
onChange={(e) => {
|
||||
setShader({ ...shader, name: e.target.value });
|
||||
setIsDirty(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="property-field">
|
||||
<label className="property-label">Version</label>
|
||||
<input
|
||||
type="number"
|
||||
className="shader-input"
|
||||
value={shader.version || 1}
|
||||
onChange={(e) => {
|
||||
setShader({ ...shader, version: parseInt(e.target.value, 10) || 1 });
|
||||
setIsDirty(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shader Source Tabs */}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">Source Code</div>
|
||||
<div className="shader-tabs">
|
||||
<button
|
||||
className={`shader-tab ${activeTab === 'vertex' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('vertex')}
|
||||
>
|
||||
<Code size={12} />
|
||||
Vertex
|
||||
</button>
|
||||
<button
|
||||
className={`shader-tab ${activeTab === 'fragment' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('fragment')}
|
||||
>
|
||||
<Code size={12} />
|
||||
Fragment
|
||||
</button>
|
||||
</div>
|
||||
<div className="shader-code-wrapper">
|
||||
<textarea
|
||||
className="shader-code-editor"
|
||||
value={activeTab === 'vertex' ? shader.vertex : shader.fragment}
|
||||
onChange={e => handleSourceChange(activeTab, e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder={`Enter ${activeTab} shader code...`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analysis Section */}
|
||||
<div className="inspector-section">
|
||||
<div
|
||||
className="section-title section-title-collapsible"
|
||||
onClick={() => setAnalysisExpanded(!analysisExpanded)}
|
||||
>
|
||||
{analysisExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<BarChart3 size={14} />
|
||||
<span>Analysis</span>
|
||||
</div>
|
||||
|
||||
{analysisExpanded && currentAnalysis && (
|
||||
<div className="shader-analysis">
|
||||
{/* Complexity Badge */}
|
||||
<div className="analysis-row">
|
||||
<span className="analysis-label">Complexity</span>
|
||||
<span className={`complexity-badge ${currentAnalysis.complexity.level}`}>
|
||||
{currentAnalysis.complexity.level.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="analysis-row">
|
||||
<span className="analysis-label">Instructions</span>
|
||||
<span className="analysis-value">~{currentAnalysis.complexity.instructionCount}</span>
|
||||
</div>
|
||||
<div className="analysis-row">
|
||||
<span className="analysis-label">Texture Samples</span>
|
||||
<span className="analysis-value">{currentAnalysis.complexity.textureSamples}</span>
|
||||
</div>
|
||||
<div className="analysis-row">
|
||||
<span className="analysis-label">Branches</span>
|
||||
<span className="analysis-value">{currentAnalysis.complexity.branches}</span>
|
||||
</div>
|
||||
<div className="analysis-row">
|
||||
<span className="analysis-label">Loops</span>
|
||||
<span className="analysis-value">{currentAnalysis.complexity.loops}</span>
|
||||
</div>
|
||||
<div className="analysis-row">
|
||||
<span className="analysis-label">Math Ops</span>
|
||||
<span className="analysis-value">{currentAnalysis.complexity.mathOps}</span>
|
||||
</div>
|
||||
|
||||
{/* Uniforms */}
|
||||
{currentAnalysis.uniforms.length > 0 && (
|
||||
<div className="analysis-group">
|
||||
<div className="analysis-group-title">
|
||||
Uniforms ({currentAnalysis.uniforms.length})
|
||||
</div>
|
||||
{currentAnalysis.uniforms.map((u, i) => (
|
||||
<div key={i} className="analysis-item">
|
||||
<span className="item-type">{u.type}</span>
|
||||
<span className="item-name">{u.name}</span>
|
||||
{u.arraySize && <span className="item-array">[{u.arraySize}]</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attributes (vertex only) */}
|
||||
{activeTab === 'vertex' && currentAnalysis.attributes.length > 0 && (
|
||||
<div className="analysis-group">
|
||||
<div className="analysis-group-title">
|
||||
Attributes ({currentAnalysis.attributes.length})
|
||||
</div>
|
||||
{currentAnalysis.attributes.map((a, i) => (
|
||||
<div key={i} className="analysis-item">
|
||||
<span className="item-type">{a.type}</span>
|
||||
<span className="item-name">{a.name}</span>
|
||||
{a.location !== undefined && (
|
||||
<span className="item-location">loc={a.location}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Varyings */}
|
||||
{currentAnalysis.varyings.length > 0 && (
|
||||
<div className="analysis-group">
|
||||
<div className="analysis-group-title">
|
||||
Varyings ({currentAnalysis.varyings.length})
|
||||
</div>
|
||||
{currentAnalysis.varyings.map((v, i) => (
|
||||
<div key={i} className="analysis-item">
|
||||
<span className={`item-qualifier ${v.qualifier}`}>{v.qualifier}</span>
|
||||
<span className="item-type">{v.type}</span>
|
||||
<span className="item-name">{v.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tips */}
|
||||
{currentAnalysis.complexity.tips.length > 0 && (
|
||||
<div className="analysis-group">
|
||||
<div className="analysis-group-title">Performance Tips</div>
|
||||
{currentAnalysis.complexity.tips.map((tip, i) => (
|
||||
<div key={i} className="analysis-tip">
|
||||
<AlertTriangle size={12} />
|
||||
<span>{tip}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{currentAnalysis.warnings.length > 0 && (
|
||||
<div className="analysis-group">
|
||||
<div className="analysis-group-title">Warnings</div>
|
||||
{currentAnalysis.warnings.map((warning, i) => (
|
||||
<div key={i} className="analysis-warning">
|
||||
<AlertTriangle size={12} />
|
||||
<span>{warning}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="analysis-group">
|
||||
<div className="analysis-group-title">Info</div>
|
||||
<div className="analysis-row">
|
||||
<span className="analysis-label">GLSL Version</span>
|
||||
<span className="analysis-value">{currentAnalysis.version}</span>
|
||||
</div>
|
||||
<div className="analysis-row">
|
||||
<span className="analysis-label">Precision</span>
|
||||
<span className="analysis-value">{currentAnalysis.precision}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shader Asset Inspector Provider.
|
||||
* 着色器资产检视器提供者。
|
||||
*/
|
||||
export class ShaderAssetInspectorProvider implements IInspectorProvider<AssetFileTarget> {
|
||||
readonly id = 'shader-asset-inspector';
|
||||
readonly name = 'Shader Asset Inspector';
|
||||
readonly priority = 100;
|
||||
|
||||
private saveHandler?: (path: string, content: string) => Promise<void>;
|
||||
|
||||
setSaveHandler(handler: (path: string, content: string) => Promise<void>): void {
|
||||
this.saveHandler = handler;
|
||||
}
|
||||
|
||||
canHandle(target: unknown): target is AssetFileTarget {
|
||||
if (typeof target !== 'object' || target === null) return false;
|
||||
const t = target as any;
|
||||
return t.type === 'asset-file' &&
|
||||
t.data?.extension?.toLowerCase() === 'shader' &&
|
||||
typeof t.content === 'string';
|
||||
}
|
||||
|
||||
render(target: AssetFileTarget, _context: InspectorContext): React.ReactElement {
|
||||
return (
|
||||
<ShaderInspectorView
|
||||
fileInfo={target.data}
|
||||
content={target.content!}
|
||||
onSave={this.saveHandler}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
105
packages/shader-editor/src/stores/ShaderEditorStore.ts
Normal file
105
packages/shader-editor/src/stores/ShaderEditorStore.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Shader Editor Store.
|
||||
* 着色器编辑器状态存储。
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* Shader data structure.
|
||||
* 着色器数据结构。
|
||||
*/
|
||||
export interface ShaderData {
|
||||
version: string;
|
||||
name: string;
|
||||
vertex: string;
|
||||
fragment: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shader editor state.
|
||||
* 着色器编辑器状态。
|
||||
*/
|
||||
export interface ShaderEditorState {
|
||||
/** Current file path. | 当前文件路径。 */
|
||||
filePath: string | null;
|
||||
/** Shader data. | 着色器数据。 */
|
||||
shaderData: ShaderData | null;
|
||||
/** Whether data has been modified. | 数据是否已修改。 */
|
||||
isDirty: boolean;
|
||||
|
||||
/** Set file path. | 设置文件路径。 */
|
||||
setFilePath: (path: string | null) => void;
|
||||
/** Set shader data. | 设置着色器数据。 */
|
||||
setShaderData: (data: ShaderData | null) => void;
|
||||
/** Set dirty flag. | 设置修改标志。 */
|
||||
setDirty: (dirty: boolean) => void;
|
||||
/** Reset state. | 重置状态。 */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create default shader data.
|
||||
* 创建默认着色器数据。
|
||||
*/
|
||||
export function createDefaultShaderData(name: string = 'New Shader'): ShaderData {
|
||||
return {
|
||||
version: '1.0',
|
||||
name,
|
||||
vertex: `#version 300 es
|
||||
precision highp float;
|
||||
|
||||
layout(location = 0) in vec2 a_position;
|
||||
layout(location = 1) in vec2 a_texCoord;
|
||||
layout(location = 2) in vec4 a_color;
|
||||
|
||||
uniform mat3 u_projection;
|
||||
|
||||
out vec2 v_texCoord;
|
||||
out vec4 v_color;
|
||||
|
||||
void main() {
|
||||
vec3 pos = u_projection * vec3(a_position, 1.0);
|
||||
gl_Position = vec4(pos.xy, 0.0, 1.0);
|
||||
v_texCoord = a_texCoord;
|
||||
v_color = a_color;
|
||||
}`,
|
||||
fragment: `#version 300 es
|
||||
precision highp float;
|
||||
|
||||
in vec2 v_texCoord;
|
||||
in vec4 v_color;
|
||||
|
||||
uniform sampler2D u_texture;
|
||||
|
||||
out vec4 fragColor;
|
||||
|
||||
void main() {
|
||||
vec4 texColor = texture(u_texture, v_texCoord);
|
||||
fragColor = texColor * v_color;
|
||||
|
||||
if (fragColor.a < 0.01) {
|
||||
discard;
|
||||
}
|
||||
}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shader editor store.
|
||||
* 着色器编辑器存储。
|
||||
*/
|
||||
export const useShaderEditorStore = create<ShaderEditorState>((set) => ({
|
||||
filePath: null,
|
||||
shaderData: null,
|
||||
isDirty: false,
|
||||
|
||||
setFilePath: (path) => set({ filePath: path }),
|
||||
setShaderData: (data) => set({ shaderData: data }),
|
||||
setDirty: (dirty) => set({ isDirty: dirty }),
|
||||
reset: () => set({
|
||||
filePath: null,
|
||||
shaderData: null,
|
||||
isDirty: false
|
||||
})
|
||||
}));
|
||||
286
packages/shader-editor/src/styles/ShaderInspector.css
Normal file
286
packages/shader-editor/src/styles/ShaderInspector.css
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Shader Inspector Styles.
|
||||
* 着色器检视器样式。
|
||||
*/
|
||||
|
||||
.shader-inspector {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.shader-dirty-indicator {
|
||||
color: var(--warning-color, #fbbf24);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.shader-error,
|
||||
.shader-loading {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #888);
|
||||
}
|
||||
|
||||
.shader-error {
|
||||
color: var(--error-color, #f87171);
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.shader-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
background: var(--bg-secondary, #252526);
|
||||
}
|
||||
|
||||
.shader-toolbar-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 4px;
|
||||
background: var(--button-bg, #333);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.shader-toolbar-btn:hover:not(:disabled) {
|
||||
background: var(--button-hover-bg, #444);
|
||||
border-color: var(--accent-color, #0078d4);
|
||||
}
|
||||
|
||||
.shader-toolbar-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Status */
|
||||
.shader-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.shader-status.success {
|
||||
background: var(--success-bg, #1a3a1a);
|
||||
color: var(--success-color, #4ade80);
|
||||
}
|
||||
|
||||
.shader-status.error {
|
||||
background: var(--error-bg, #3a2020);
|
||||
color: var(--error-color, #f87171);
|
||||
}
|
||||
|
||||
/* Input */
|
||||
.shader-input {
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 3px;
|
||||
background: var(--input-bg, #333);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.shader-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color, #0078d4);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.shader-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-color, #333);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.shader-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-secondary, #888);
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.shader-tab:hover {
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.shader-tab.active {
|
||||
color: var(--accent-color, #0078d4);
|
||||
border-bottom-color: var(--accent-color, #0078d4);
|
||||
}
|
||||
|
||||
/* Code Editor */
|
||||
.shader-code-wrapper {
|
||||
position: relative;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shader-code-editor {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
max-height: 400px;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
background: var(--bg-tertiary, #1a1a1a);
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
tab-size: 4;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.shader-code-editor::placeholder {
|
||||
color: var(--text-tertiary, #555);
|
||||
}
|
||||
|
||||
/* Analysis */
|
||||
.shader-analysis {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.analysis-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.analysis-label {
|
||||
color: var(--text-secondary, #888);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.analysis-value {
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Complexity Badge */
|
||||
.complexity-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.complexity-badge.low {
|
||||
background: var(--success-bg, #1a3a1a);
|
||||
color: var(--success-color, #4ade80);
|
||||
}
|
||||
|
||||
.complexity-badge.medium {
|
||||
background: var(--info-bg, #1a2a3a);
|
||||
color: var(--info-color, #60a5fa);
|
||||
}
|
||||
|
||||
.complexity-badge.high {
|
||||
background: var(--warning-bg, #3a3a1a);
|
||||
color: var(--warning-color, #fbbf24);
|
||||
}
|
||||
|
||||
.complexity-badge.very-high {
|
||||
background: var(--error-bg, #3a2020);
|
||||
color: var(--error-color, #f87171);
|
||||
}
|
||||
|
||||
/* Analysis Groups */
|
||||
.analysis-group {
|
||||
margin-top: 12px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-color, #333);
|
||||
}
|
||||
|
||||
.analysis-group-title {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #888);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* Analysis Items */
|
||||
.analysis-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 6px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-tertiary, #1e1e1e);
|
||||
font-size: 10px;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
}
|
||||
|
||||
.item-type {
|
||||
color: var(--accent-color, #0078d4);
|
||||
}
|
||||
|
||||
.item-name {
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.item-array {
|
||||
color: var(--text-tertiary, #666);
|
||||
}
|
||||
|
||||
.item-location {
|
||||
color: var(--text-tertiary, #666);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.item-qualifier {
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.item-qualifier.in {
|
||||
background: var(--success-bg, #1a3a1a);
|
||||
color: var(--success-color, #4ade80);
|
||||
}
|
||||
|
||||
.item-qualifier.out {
|
||||
background: var(--info-bg, #1a2a3a);
|
||||
color: var(--info-color, #60a5fa);
|
||||
}
|
||||
|
||||
/* Tips and Warnings */
|
||||
.analysis-tip,
|
||||
.analysis-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
background: var(--warning-bg, #3a3a1a);
|
||||
color: var(--warning-color, #fbbf24);
|
||||
}
|
||||
|
||||
.analysis-tip svg,
|
||||
.analysis-warning svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
9
packages/shader-editor/tsconfig.build.json
Normal file
9
packages/shader-editor/tsconfig.build.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"declaration": true,
|
||||
"declarationDir": "./dist"
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
16
packages/shader-editor/tsconfig.json
Normal file
16
packages/shader-editor/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"references": [
|
||||
{ "path": "../core" },
|
||||
{ "path": "../editor-core" },
|
||||
{ "path": "../material-system" }
|
||||
]
|
||||
}
|
||||
7
packages/shader-editor/tsup.config.ts
Normal file
7
packages/shader-editor/tsup.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
import { editorOnlyPreset } from '../build-config/src/presets/plugin-tsup';
|
||||
|
||||
export default defineConfig({
|
||||
...editorOnlyPreset(),
|
||||
tsconfig: 'tsconfig.build.json'
|
||||
});
|
||||
Reference in New Issue
Block a user