尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Unity Shader源码导出工具开发:从lilToon项目提取可编辑HLSL代码

Unity Shader源码导出工具开发:从lilToon项目提取可编辑HLSL代码 1. 项目概述为什么我们需要从lilToon项目中导出Shader源码如果你在Unity里折腾过角色渲染尤其是VRChat或者类似的虚拟形象项目那你大概率听说过或者用过lilToon。这个开源项目以其丰富的功能、优秀的性能和活跃的社区几乎成了很多二次元风格角色渲染的“标配”Shader。但不知道你有没有遇到过这样的场景你拿到一个用lilToon制作的模型想深入研究它的渲染逻辑或者想基于它进行一些定制化的魔改却发现手头只有编译好的.shader文件或者更糟只有一个打包好的.unitypackage里面的Shader是Unity编译后的中间格式根本看不到原始的HLSL或CG代码。这时候一个“Shader源码导出”功能就显得至关重要了。它不仅仅是把.shader文件复制出来那么简单。lilToon的Shader结构复杂包含了大量的宏定义、条件编译、以及通过C#脚本动态生成的Shader变体。直接查看Unity编辑器里的Shader Inspector你看到的只是最终组装好的、经过Unity预处理后的代码很多原始的编程逻辑和结构信息已经丢失了。真正的“源码”指的是构成这个Shader的最基础的、可编辑的HLSL代码块、属性定义和完整的Pass结构。实现这个导出功能意味着我们能穿透Unity的封装直接触达Shader的“心脏”这对于学习、调试、深度定制乃至二次开发都有着不可替代的价值。简单来说这个功能解决的核心痛点就是将lilToon中那些经过复杂组装和预处理的、用户友好的Shader定义逆向还原成一份结构清晰、可独立编译、便于人类阅读和修改的“工程源码”。这不仅仅是文件格式的转换更是一次对Shader资产内部逻辑的深度解析与重构。2. 核心需求与功能边界解析在动手之前我们必须明确我们要导出的“源码”具体指什么以及这个功能的边界在哪里。盲目开始只会陷入泥潭。2.1 需求拆解我们到底要导出什么基于lilToon的项目结构和使用场景我们可以将导出需求分解为以下几个层次基础HLSL代码块这是Shader的灵魂。lilToon将不同的光照模型、渲染效果如描边、雾效、各向异性高光等封装在独立的.hlsl或.cginc文件中。例如lil_common.hlsl,lil_lighting.hlsl等。导出功能必须能完整地提取这些文件并保持其原有的依赖关系和#include路径逻辑。ShaderLab主体结构即.shader文件本身。这包括了Properties块定义在材质面板上显示的参数、各个SubShader和Pass的定义。关键点在于lilToon使用了大量的#pragma multi_compile和#pragma shader_feature来管理功能开关和性能分级。导出时需要保留这些编译指令因为它们定义了Shader的变体空间。C#脚本生成的Shader变体这是lilToon的一大特色。为了管理海量的功能组合例如是否开启镜面反射、是否使用双面渲染、是否启用顶点动画等lilToon通过一个C#脚本通常是lilToonSetting.cs或类似的生成器来动态生成包含不同功能集的Shader变体。导出的源码必须能够反映这种生成逻辑或者至少导出最终生成的所有Shader变体文件。配套的资源和设置包括内置的纹理如噪声图、Ramp图、.cginc引用、以及一些项目特定的设置文件。缺少这些导出的Shader可能无法正确编译或运行。2.2 功能边界与挑战明确了目标我们还要看清限制和难点挑战一反编译与还原。如果我们手头只有Unity编译后的二进制数据比如从AssetBundle中提取的那么这就是一个反编译问题。我们需要解析Unity的Shader中间表示如DXBC/SPIR-V并尝试逆向成HLSL。这非常困难且得到的代码可读性极差变量名会丢失结构也会被打乱。幸运的是对于lilToon这样的开源项目我们通常能直接访问其GitHub仓库所以我们的任务更多是“源码提取与重组”而非“二进制反编译”。这大大降低了难度。挑战二依赖解析。Shader文件之间通过#include形成了复杂的依赖网。导出时不能只复制单个文件必须递归地找出所有被引用的头文件并确保在新的目录结构下这些#include指令依然有效。可能需要重写相对路径或者采用一种扁平化的打包方式。挑战三变体重构。动态生成的Shader变体可能多达数十上百个。我们是导出一个“母体”Shader加上所有变体文件还是导出一个能够模拟原生成脚本的、可配置的单一Shader文件这涉及到易用性和完整性的权衡。边界本功能主要面向拥有lilToon项目源代码即从GitHub克隆或下载的完整项目的用户。对于仅有编译后资产的情况本方案可能无法直接应用需要借助更底层的Unity编辑器API或第三方反编译工具但那已是另一个范畴的课题。3. 技术方案设计与核心工具选型基于“源码提取与重组”的定位我们的技术方案可以设计为一个运行在Unity编辑器环境下的C#工具。它将扫描项目中的lilToon相关文件分析其结构然后输出一个完整的、可移植的源码包。3.1 整体架构设计工具的核心工作流可以概括为“扫描 - 分析 - 复制/重构 - 打包”。扫描阶段使用AssetDatabase.FindAssetsAPI通过特定的标签如t:Shader且名称包含 “lilToon”或路径过滤定位项目中所有的lilToon Shader文件、相关的HLSL Include文件以及关键的C#生成脚本。分析阶段对于每个Shader文件使用ShaderUtil.GetShaderPropertyCount和ShaderUtil.GetPropertyDescription来枚举所有属性以便后续生成准确的Properties块。读取Shader文本内容使用正则表达式解析所有的#include指令建立文件依赖图。分析C#生成脚本理解其生成Shader变体的逻辑例如读取一个配置列表循环生成不同的#pragma组合。复制/重构阶段直接复制对于HLSL头文件.hlsl,.cginc和纹理资源直接复制到输出目录。重构ShaderLab文件这是关键。我们不能简单复制.shader文件因为其中的#include路径可能是基于原项目的相对路径。我们需要方案A保留路径结构在输出目录中完全镜像原项目的目录结构。这样#include无需修改但导出包的结构较深。方案B扁平化路径重写将所有HLSL文件复制到输出目录的同一个文件夹下如Includes/然后遍历每个.shader文件将其中的#include “路径/文件.hlsl”重写为#include “Includes/文件.hlsl”。这更简洁但需要小心处理同名文件冲突。处理变体生成理想情况下我们直接复制生成脚本。但有时脚本可能依赖项目内其他模块。更稳妥的方式是直接运行一次原项目的生成逻辑将生成出的所有最终版.shader文件都复制出来。这样用户拿到的是“结果”而非“过程”开箱即用。打包阶段将输出目录的所有文件打包成一个.zip压缩包或者生成一个新的.unitypackage方便用户分发和导入。3.2 核心Unity API与第三方库UnityEditor.AssetDatabase: 这是我们的“文件系统导航器”。FindAssets,LoadAssetAtPath,GUIDToAssetPath等方法是扫描和加载资产的基础。UnityEditor.ShaderUtil: 这个类提供了深入访问Shader内部信息的接口比如枚举属性、获取源码字符串GetShaderSource对于分析Shader结构不可或缺。System.Text.RegularExpressions: 用于解析Shader文本中的#include、#pragma等指令是文本处理的核心。System.IO (Directory, File, Path): 用于实际的磁盘文件操作如创建目录、复制文件、读写文本。注意由于工具需要访问UnityEditor命名空间它只能作为Unity编辑器扩展Editor Tool来运行无法在运行时Build后使用。工具界面可以是一个简单的EditorWindow提供“选择lilToon根目录”、“选择输出路径”、“开始导出”几个按钮。3.3 方案选型理由为什么不直接用文件管理器手动复制因为手动操作无法解决依赖分析和路径重写问题极易遗漏文件或导致编译错误。为什么不使用Git的archive功能Git归档虽然能打包源码但它包含的是整个项目历史且无法针对Unity项目的资产依赖进行智能提取和重构。我们设计的这个工具目的就是做一件“针对性极强的自动化脏活累活”确保输出的源码包是独立、完整、可立即编译的。4. 关键实现步骤与代码解析接下来我们深入到具体的代码层面。我会以一个简化但核心逻辑完整的C#编辑器脚本为例拆解实现过程。4.1 第一步创建编辑器窗口与用户界面首先我们创建一个简单的UI让用户操作。using UnityEditor; using UnityEngine; using System.IO; using System.Collections.Generic; public class LilToonShaderExporter : EditorWindow { private string lilToonRootPath Assets/lilToon; // 默认路径 private string exportPath ExportedLilToonShader; [MenuItem(Tools/lilToon Shader Exporter)] public static void ShowWindow() { GetWindowLilToonShaderExporter(lilToon Exporter); } void OnGUI() { GUILayout.Label(lilToon Shader Source Exporter, EditorStyles.boldLabel); EditorGUILayout.Space(); // 选择lilToon根目录 GUILayout.Label(lilToon Root Folder:); EditorGUILayout.BeginHorizontal(); lilToonRootPath EditorGUILayout.TextField(lilToonRootPath); if (GUILayout.Button(Browse..., GUILayout.Width(80))) { string newPath EditorUtility.OpenFolderPanel(Select lilToon Root, Application.dataPath, ); if (!string.IsNullOrEmpty(newPath)) { // 将绝对路径转换为相对于项目的路径 if (newPath.StartsWith(Application.dataPath)) { lilToonRootPath Assets newPath.Substring(Application.dataPath.Length); } else { Debug.LogWarning(Selected folder must be inside the Unity project Assets folder.); } } } EditorGUILayout.EndHorizontal(); // 选择导出路径 GUILayout.Label(Export To Folder (relative to project root):); exportPath EditorGUILayout.TextField(exportPath); EditorGUILayout.Space(); if (GUILayout.Button(Export Shader Source)) { if (Directory.Exists(lilToonRootPath)) { ExportShaderSource(); } else { EditorUtility.DisplayDialog(Error, lilToon root folder does not exist!, OK); } } } }这个窗口提供了两个路径输入框和一个导出按钮。核心逻辑在ExportShaderSource方法中。4.2 第二步递归收集所有相关文件这是工具的核心。我们需要找到所有Shader、HLSL Include和关键资源。private void ExportShaderSource() { string fullExportPath Path.Combine(Directory.GetCurrentDirectory(), exportPath); if (!Directory.Exists(fullExportPath)) { Directory.CreateDirectory(fullExportPath); } Liststring filesToExport new Liststring(); // 1. 收集所有.shader文件 string[] shaderGUIDs AssetDatabase.FindAssets(t:Shader, new[] { lilToonRootPath }); foreach (string guid in shaderGUIDs) { string path AssetDatabase.GUIDToAssetPath(guid); if (path.Contains(lilToon)) // 简单过滤可根据需要加强 { filesToExport.Add(path); Debug.Log($Found Shader: {path}); } } // 2. 收集所有.hlsl和.cginc文件 (通过文件扩展名扫描) // 注意AssetDatabase.FindAssets 对 .hlsl 文件类型支持可能不直接我们改为遍历目录 CollectFilesByExtension(lilToonRootPath, filesToExport, .hlsl); CollectFilesByExtension(lilToonRootPath, filesToExport, .cginc); // 3. 收集关键的纹理和资源例如内置的Noise贴图 // 这里可以根据已知的文件名列表来收集例如 string[] knownTextures new string[] { lil_noise.png, lil_ramp.png }; foreach (var texName in knownTextures) { string[] texGUIDs AssetDatabase.FindAssets(texName, new[] { lilToonRootPath }); foreach (string guid in texGUIDs) { filesToExport.Add(AssetDatabase.GUIDToAssetPath(guid)); } } // 4. 收集C#生成脚本 string[] scriptGUIDs AssetDatabase.FindAssets(lilToonGenerator t:Script, new[] { lilToonRootPath }); foreach (string guid in scriptGUIDs) { filesToExport.Add(AssetDatabase.GUIDToAssetPath(guid)); } // 5. 分析并收集依赖的Include文件关键步骤 HashSetstring allIncludeFiles new HashSetstring(); foreach (var filePath in filesToExport.ToArray()) // 遍历副本因为可能会添加新文件 { if (filePath.EndsWith(.shader) || filePath.EndsWith(.hlsl) || filePath.EndsWith(.cginc)) { CollectIncludeDependencies(filePath, allIncludeFiles, lilToonRootPath); } } // 将新发现的依赖文件加入主列表 foreach (var inc in allIncludeFiles) { if (!filesToExport.Contains(inc)) { filesToExport.Add(inc); } } // 6. 复制文件到导出目录这里先实现简单复制路径问题后续处理 CopyFilesToExport(filesToExport, lilToonRootPath, fullExportPath); AssetDatabase.Refresh(); EditorUtility.DisplayDialog(Success, $Shader source exported to: {fullExportPath}, OK); } // 辅助函数递归收集指定扩展名的文件 private void CollectFilesByExtension(string rootPath, Liststring fileList, string extension) { string fullRootPath Path.Combine(Application.dataPath.Substring(0, Application.dataPath.Length - Assets.Length), rootPath); if (Directory.Exists(fullRootPath)) { foreach (string file in Directory.GetFiles(fullRootPath, * extension, SearchOption.AllDirectories)) { string relativePath Assets file.Substring(Application.dataPath.Length); fileList.Add(relativePath); } } }4.3 第三步解析Shader依赖核心中的核心CollectIncludeDependencies函数是确保完整性的关键。它需要读取Shader文件内容用正则表达式找出所有的#include指令。using System.Text.RegularExpressions; ... private void CollectIncludeDependencies(string filePath, HashSetstring includeSet, string rootPath) { if (!File.Exists(filePath)) return; string shaderSource File.ReadAllText(filePath); // 匹配 #include 指令处理带括号和不带括号的情况 // 例如: #include lil_common.hlsl 或 #include lil_lighting.hlsl Regex includeRegex new Regex(#include\s[]([^])[], RegexOptions.Multiline); var matches includeRegex.Matches(shaderSource); foreach (Match match in matches) { string includePath match.Groups[1].Value; string resolvedPath ResolveIncludePath(includePath, filePath, rootPath); if (!string.IsNullOrEmpty(resolvedPath) File.Exists(resolvedPath)) { if (includeSet.Add(resolvedPath)) // 如果成功添加即未重复 { // 递归查找这个include文件自身的依赖 CollectIncludeDependencies(resolvedPath, includeSet, rootPath); } } else { Debug.LogWarning($Could not resolve include path: {includePath} in file: {filePath}); } } } private string ResolveIncludePath(string includePath, string referencingFilePath, string rootPath) { // 情况1: 绝对路径相对于项目Assets if (includePath.StartsWith(Assets/)) { return includePath; } // 情况2: 相对路径相对于引用文件 string referencingDir Path.GetDirectoryName(referencingFilePath); string combinedPath Path.Combine(referencingDir, includePath).Replace(\\, /); // 规范化路径处理 ./ 和 ../ combinedPath Path.GetFullPath(Path.Combine(Application.dataPath.Substring(0, Application.dataPath.Length - Assets.Length), combinedPath)); string relativeToAssets Assets combinedPath.Substring(Application.dataPath.Length); if (File.Exists(relativeToAssets)) { return relativeToAssets; } // 情况3: 可能是相对于lilToon根目录的路径或者是Unity内置路径如UnityCG.cginc // 对于lilToon我们假设其内部引用都相对于项目。Unity内置的我们忽略。 if (!includePath.Contains(Unity)) // 简单过滤掉Unity内置头文件 { // 尝试在lilToon根目录下查找 string searchPattern Path.GetFileName(includePath); string[] found Directory.GetFiles(Application.dataPath.Substring(0, Application.dataPath.Length - Assets.Length) rootPath, searchPattern, SearchOption.AllDirectories); if (found.Length 0) { return Assets found[0].Substring(Application.dataPath.Length); } } return null; }这个ResolveIncludePath函数尝试了三种常见的路径解析方式基本能覆盖lilToon项目内部的大部分情况。对于Unity内置头文件如UnityCG.cginc我们选择忽略因为它们在任何Unity项目中都存在。4.4 第四步复制文件与路径重写收集完所有文件后我们需要将它们复制到目标文件夹。这里采用方案B扁平化路径重写因为它输出的结构更清晰。private void CopyFilesToExport(Liststring sourceFiles, string rootPath, string exportBasePath) { string includesFolder Path.Combine(exportBasePath, Includes); Directory.CreateDirectory(includesFolder); foreach (string sourceFile in sourceFiles) { string extension Path.GetExtension(sourceFile).ToLower(); string destFile; if (extension .hlsl || extension .cginc) { // HLSL文件全部放到Includes文件夹下 string fileName Path.GetFileName(sourceFile); destFile Path.Combine(includesFolder, fileName); // 处理可能的重名文件不同子目录下的同名.hlsl if (File.Exists(destFile)) { // 简单策略添加父目录名前缀 string parentDirName new DirectoryInfo(Path.GetDirectoryName(sourceFile)).Name; fileName ${parentDirName}_{fileName}; destFile Path.Combine(includesFolder, fileName); } File.Copy(sourceFile, destFile, true); Debug.Log($Copied HLSL: {sourceFile} - {destFile}); } else if (extension .shader) { // Shader文件保持原目录结构相对于lilToon根目录但需要重写其中的include路径 string relativePath GetRelativePath(rootPath, sourceFile); destFile Path.Combine(exportBasePath, relativePath); Directory.CreateDirectory(Path.GetDirectoryName(destFile)); string shaderContent File.ReadAllText(sourceFile); // 重写 #include 路径指向扁平化的Includes文件夹 shaderContent RewriteIncludePaths(shaderContent, Path.GetFileName(sourceFile)); File.WriteAllText(destFile, shaderContent); Debug.Log($Processed Copied Shader: {sourceFile} - {destFile}); } else { // 其他文件如.png, .cs保持原结构复制 string relativePath GetRelativePath(rootPath, sourceFile); destFile Path.Combine(exportBasePath, relativePath); Directory.CreateDirectory(Path.GetDirectoryName(destFile)); File.Copy(sourceFile, destFile, true); Debug.Log($Copied Resource: {sourceFile} - {destFile}); } } } private string GetRelativePath(string rootPath, string fullPath) { Uri rootUri new Uri(Path.GetFullPath(rootPath) Path.DirectorySeparatorChar); Uri fullUri new Uri(Path.GetFullPath(fullPath)); return Uri.UnescapeDataString(rootUri.MakeRelativeUri(fullUri).ToString().Replace(/, Path.DirectorySeparatorChar)); } private string RewriteIncludePaths(string shaderContent, string shaderFileName) { // 这个正则和之前一样但这次我们要替换匹配到的整个#include行 Regex includeRegex new Regex((#include\s[])([^])([]), RegexOptions.Multiline); string result includeRegex.Replace(shaderContent, match { string originalPath match.Groups[2].Value; // 如果已经是相对路径或者是我们不处理的Unity内置路径则跳过 if (originalPath.StartsWith(Includes/) || originalPath.Contains(Unity)) { return match.Value; // 保持不变 } // 否则重写为指向Includes文件夹下的文件名 string fileNameOnly Path.GetFileName(originalPath); // 注意这里假设了扁平化后文件名唯一。如果之前重命名了如parent_dir_file.hlsl这里需要映射。 // 为了简化我们这里直接使用文件名。一个更健壮的实现需要维护一个源路径到扁平化后路径的映射表。 return ${match.Groups[1].Value}Includes/{fileNameOnly}{match.Groups[3].Value}; }); return result; }重要提示上面的RewriteIncludePaths函数是一个简化版。在实际项目中由于我们可能对重名的HLSL文件进行了重命名如添加了前缀这里需要用一个字典来记录原始路径 - 扁平化后路径的映射并在重写时使用这个映射。否则#include指令可能会指向一个不存在的文件。这是一个需要完善的细节。5. 高级功能与边界情况处理基础的文件收集和复制完成后一个健壮的工具还需要处理一些复杂情况。5.1 处理Shader变体生成器lilToon可能通过一个C#脚本根据功能开关#pragma shader_feature的组合批量生成几十个具体的Shader文件。我们的工具可以这样处理识别生成器通过文件名如*Generator.cs,*Builder.cs或分析其内容查找ShaderUtil.CreateShaderAsset等API调用来定位生成脚本。模拟执行最直接的方法是在导出工具的上下文中反射调用这个生成器脚本的主要方法。这要求生成器脚本本身是独立、可执行的并且不依赖特定的编辑器状态。// 伪代码 System.Type generatorType System.Reflection.Assembly.GetExecutingAssembly().GetType(lilToon.Generator); if (generatorType ! null) { System.Reflection.MethodInfo method generatorType.GetMethod(GenerateAllShaders); if (method ! null method.IsStatic) { method.Invoke(null, null); // 执行生成 // 生成后重新扫描.shader文件确保新生成的也被收集 } }收集生成结果执行生成后再次扫描项目收集所有新出现的、名称符合lilToon模式的.shader文件并将它们纳入复制和重写流程。5.2 处理自定义的CGINC或HLSL库有时lilToon可能会依赖项目内其他位置的、非其自身目录下的HLSL文件。我们的依赖解析函数ResolveIncludePath的“情况3”尝试在整个lilToonRootPath下搜索这能解决一部分问题。但对于更外部的依赖我们需要提供一个“额外搜索路径”的配置选项给用户或者输出警告日志让用户手动处理这些外部依赖。5.3 导出包的完整性验证在复制完成后可以增加一个简单的验证步骤检查每个导出的.shader文件尝试解析其#include指令确认指向的文件在导出目录中都存在。可以尝试使用ShaderUtil.OpenShader在内存中加载一下导出的关键Shader如果没有报错则说明基本结构是完整的。注意这只是一个快速检查不能保证所有功能都正确。6. 常见问题与避坑指南在实际实现和使用这个导出工具的过程中我踩过不少坑这里总结一下路径分隔符问题Windows用\macOS/Linux用/。在代码中处理路径时务必使用Path.Combine()和Replace(“\\”, “/”)来保证跨平台一致性。正则表达式匹配#include时也要考虑路径中可能包含的斜杠方向。GUID与AssetDatabase的延迟AssetDatabase的某些操作不是立即生效的。如果你在工具运行过程中创建或修改了资产比如执行了Shader生成器记得调用AssetDatabase.Refresh()并适当等待可以用EditorApplication.delayCall再继续扫描否则可能找不到新文件。同名HLSL文件冲突正如前面提到的扁平化策略会导致不同子目录下的同名.hlsl文件冲突。务必实现一个冲突解决策略。我的建议是不仅添加父目录前缀最好维护一个映射字典Dictionarystring, string记录原始路径到新文件名的映射并在重写Shader内容时严格使用这个字典进行替换。Shader编译错误导出的Shader在新项目中编译报错通常是以下原因缺少Unity内置变量lilToon可能使用了某些Unity提供的全局变量或宏如UNITY_MATRIX_MVP在旧版中。确保你的目标项目使用的Unity版本和渲染管线Built-in/URP/HDRP与源项目兼容。对于URP/HDRP可能需要额外导出并安装对应的RenderPipeline核心HLSL库。自定义宏未定义检查原项目中是否有通过C#代码Shader.SetGlobalFloat等方式定义的全局Shader变量。这些需要在运行时设置导出工具无法捕获。需要在文档中说明。纹理采样器状态不一致如果原项目对某些纹理有特殊的采样器设置导出后可能丢失。需要仔细对比原Shader的Properties和Pass中的纹理声明。性能问题如果项目非常大递归扫描和文件复制可能比较耗时。可以考虑将长时间操作放在EditorUtility.DisplayProgressBar进度条中提升用户体验。对于已经导出过的、未修改的文件可以实现增量导出逻辑记录文件的哈希值。关于“Missing Global Shader”错误如果你导出的Shader在新项目中提示“Missing Global Shader”这通常不是因为导出工具的问题而是因为该Shader被标记为“Global Shader”通过Shader.globalShader或Shader.Find(“Global/XXX”)注册。全局Shader需要在Unity启动时或通过特定API注册。对于lilToon通常它不是全局Shader。如果遇到此错误检查一下是不是导出的.shader文件本身不完整或者其依赖的某个CGINC文件引用了不存在的全局Shader。最后这个工具生成的源码包最适合用于学习、备份和基于相同Unity环境或非常相似环境的二次开发。如果你想将它用于一个完全不同渲染管线或Unity版本的项目可能还需要大量的手动适配工作。导出工具为你提供了清晰的起点但深入的整合依然需要开发者对Shader本身有足够的理解。
返回列表