- 游戏开发
- 图形学
- VR
【免费下载链接】stride
Stride (formerly Xenko), a free and open-source cross-platform C# game engine.
CppNet 是 Stride 引擎仓库中附带的一个 C 语言预处理器(C Preprocessor)实现:它将 Java 世界的 JCPP(Anarres C Preprocessor)快速移植到 C#,并针对 Clang 预处理场景做了增强。本文以 deps/CppNet/README.md 为核心脉络,结合 Stride 仓库内实际 vendored 的源码与着色器管线中的真实调用,系统讲解它的特性开关、宏系统、条件编译、include 解析、Clang 扩展指令(__has_include/__has_feature等)以及它在 Stride 的 SDSL 着色器预处理流程中的实际用途。读完本文,你将能够独立配置一个 CppNetPreprocessor实例、理解其底层 token 流与状态栈工作机制,并知道如何在 Stride 中复用它预处理 C/C++/Objective-C 风格源码。
CppNet 是什么:从 JCPP 到 C# 的移植
按照 deps/CppNet/README.md 的定义,CppNet 是 JCPP 的"quick and dirty"(快速粗糙)C# 移植版,其目标是支持 Clang 风格的预处理。JCPP(Anarres C Preprocessor)是 Shevek 维护的 Java 实现,CppNet 在保留其整体架构的基础上,额外添加了以下三个能力:
__has_include、__has_include_next、__has_feature三个 Clang 扩展指令;- 可变参数宏(variadic macros);
#import指令。
在 Stride 仓库中,CppNet 以两种形态存在:
- 预编译产物:deps/CppNet/netstandard1.3/CppNet.dll(netstandard1.3 目标框架);
- vendored 源码:sources/shaders/Stride.Shaders.Parsers/CppNet/ 目录下完整保留了全部 C# 源文件,其内部的 README.md 注明该副本来自 MonoGame/CppNet 上游仓库,固定于 commit
3dc3eb2db05be9d80b7cc5d00d5814cbe8f0ded2("Make all public API internal for MonoGame Pipeline use. (#2)")。也就是说,Stride 拿到的这份副本把原先公开的 API 全部改成了internal,仅供引擎内部管线使用;而 deps/CppNet/README.md 中演示的仍是公开 API 形态的调用写法。
同时,该 vendored README 也列出了未被 Stride 使用的上游文件(未随源码复制):CppReader.cs、CppTask.cs、InputLexerSource.cs、TokenSnifferSource.cs、CppNet.csproj、CppNet.targets、Properties/AssemblyInfo.cs。许可证条款见 deps/CppNet/LICENSE(Apache License 2.0,源码头部也保留了同样的版权声明)。
核心对象模型与整体架构
从 Preprocessor.cs 的源码结构看,CppNet 的架构与 JCPP 一脉相承,核心由以下几部分组成:
Preprocessor(Preprocessor.cs):实现了IDisposable,是整个预处理引擎。它维护:inputs:待处理的Source列表(按添加顺序依次处理);macros:Dictionary<string, Macro>宏表,构造时即内置__LINE__、__FILE__、__COUNTER__三个特殊宏;states:Stack<State>条件编译状态栈(#if/#else/#endif的嵌套靠它维护);source:当前正在读取的顶层Source(include 时通过push_source/pop_source压栈/弹栈);quoteincludepath、sysincludepath、frameworkspath:分别对应-iquote、-I与 Objective-C frameworks 搜索路径;features/warnings:Feature与Warning两个位标志集合;filesystem:默认JavaFileSystem的VirtualFileSystem实例,负责把路径解析为虚拟文件;listener:PreprocessorListener事件监听器。
Source族:FileLexerSource.cs、StringLexerSource、LexerSource、FixedTokenSource、MacroTokenSource等,统一提供token()接口,是词法输入流的抽象。FileLexerSource提供三个构造重载:(FileInfo)、(FileInfo, string path)、(string path),底层用StreamReader带缓冲读取。VirtualFile/VirtualFileSystem:可插拔的虚拟文件系统,setFileSystem允许完全替换路径解析逻辑。Token:预处理输出是"无需再次词法化"的 token 流,Token.getText()拼接即可还原文本。
预处理器的入口方法有两个:token()返回下一个预处理 token,token_nonwhite()跳过空白/注释后返回下一个有效 token。当没有安装监听器时,源码中error()/warning()会直接抛出LexerException(见 Preprocessor.cs)。
特性开关:Feature 与 Warning 位标志
CppNet 用两个[Flags]枚举精细控制行为,均定义在独立文件中:
Feature(Feature.cs):
| 枚举值 | 位 | 含义 |
|---|---|---|
NONE | 0 | 无特性 |
DIGRAPHS | 1<<0 | 支持 ANSI 双字符组(digraphs) |
TRIGRAPHS | 1<<1 | 支持 ANSI 三字符组(trigraphs) |
LINEMARKERS | 1<<2 | 输出行标记(linemarker)token |
CSYNTAX | 1<<3 | 将INVALID类型 token 报告为错误 |
KEEPCOMMENTS | 1<<4 | 在词法输出中保留注释 |
KEEPALLCOMMENTS | 1<<5 | 即使处于非激活分支也保留注释 |
VERBOSE | 1<<6 | 冗余输出 |
DEBUG | 1<<7 | 调试输出(打印到 stderr) |
OBJCSYNTAX | 1<<8 | 支持 Objective-C 词法(如@符号) |
INCLUDENEXT | 1<<9 | 启用include_next指令 |
Feature.DEBUG在 Preprocessor.cs 等多处控制Console.Error输出,方便排查宏展开过程。
Warning(Warning.cs):
| 枚举值 | 位 | 含义 |
|---|---|---|
NONE | 0 | 无警告 |
TRIGRAPHS | 1<<0 | 三字符组相关警告 |
IMPORT | 1<<1 | #import相关警告 |
UNDEF | 1<<2 | 条件表达式中出现未定义标识符时告警 |
UNUSED_MACROS | 1<<3 | 未使用的宏 |
ENDIF_LABELS | 1<<4 | #endif标签检查 |
ERROR | 1<<5 | 把警告升级为错误 |
特别注意Warning.ERROR:开启后,warning() 方法会直接转调error(),让警告变成硬错误;而Warning.UNDEF则会在#if表达式遇到未定义标识符时发出告警(见 Preprocessor.cs)。
快速上手:README 示例逐行解析
deps/CppNet/README.md 给出了一个完整可运行的配置示例(预处理一个 iOS ARM64 平台的 Objective-C 文件test.m),逐行解读如下:
var pp = new Preprocessor(); pp.addFeature(Feature.DIGRAPHS); pp.addFeature(Feature.TRIGRAPHS); pp.addFeature(Feature.OBJCSYNTAX); pp.addWarning(Warning.IMPORT); pp.addFeature(Feature.INCLUDENEXT); pp.setListener(new PreprocessorListener()); pp.getSystemIncludePath().Add(@"C:\XcodeDefault.xctoolchain\usr\include"); pp.getSystemIncludePath().Add(@"C:\XcodeDefault.xctoolchain\usr\lib\clang\6.0\include"); pp.getFrameworksPath().Add(@"C:\iPhoneOS8.0.sdk\System\Library\Frameworks"); pp.getSystemIncludePath().Add(@"C:\iPhoneOS8.0.sdk\usr\include"); pp.addMacro("__AARCH64_SIMD__"); pp.addMacro("__ARM64_ARCH_8__"); pp.addMacro("__ARM_NEON__"); pp.addMacro("__LITTLE_ENDIAN__"); pp.addMacro("__REGISTER_PREFIX__", ""); pp.addMacro("__arm64", "1"); pp.addMacro("__arm64__", "1"); pp.addMacro("__APPLE_CC__", "6000"); pp.addMacro("__APPLE__"); pp.addMacro("__GNUC__", "4"); pp.addMacro("OBJC_NEW_PROPERTIES"); pp.addMacro("__STDC_HOSTED__", "1"); pp.addMacro("__MACH__"); Version version = new Version("8.0.0.0"); pp.addMacro("__ENVIRONMENT_IPHONE_OS_VERSION_MIN_REQUIRED__", string.Format("{0:0}{1:00}{2:00}", version.Major, version.Minor, version.Revision)); pp.addMacro("__STATIC__"); pp.addInput(new FileLexerSource("test.m"));这段代码蕴含了 CppNet 的完整使用套路:
- 开关配置:
addFeature打开 digraphs/trigraphs、Objective-C 语法、include_next;addWarning打开#import警告;setListener挂接事件监听器(不挂监听器时错误/警告会直接抛异常)。 - 搜索路径:
getSystemIncludePath()返回的List<string>可自由修改,等价于编译器的-I;getFrameworksPath()对应 Objective-C 的 frameworks 搜索路径。include()的解析顺序(见 Preprocessor.cs)为:带引号的 include 先查当前文件所在目录,再查 quote 路径,然后查系统 include 路径,最后查 frameworks 路径。 - 预定义宏:
addMacro(name)等价于addMacro(name, "1")(源码注释明确说明);addMacro(name, value)会把 value 用StringLexerSource词法化为 token 流作为宏展开体;addMacro("__REGISTER_PREFIX__", "")表示定义为空字符串。这里定义的__APPLE__、__arm64、__ARM_NEON__、OBJC_NEW_PROPERTIES等,正是 Clang 在 Apple ARM64 目标上会自动定义的平台宏,目的是让被预处理的头文件认为自己运行在真实 Clang 环境中。__ENVIRONMENT_IPHONE_OS_VERSION_MIN_REQUIRED__则用Version("8.0.0.0")格式化出形如80000的部署目标版本号。 - 输入源:
addInput(new FileLexerSource("test.m"))把文件作为预处理入口。
宏系统:定义、展开、可变参数、字符串化与粘贴
宏是预处理器的灵魂。CppNet 中:
addMacro有三种重载:addMacro(Macro m)、addMacro(string name, string value)、addMacro(string name)。底层 addMacro(Macro) 会拒绝名为defined的宏(抛出LexerException("Cannot redefine name 'defined'"))。- 宏体由
Macro类封装(Macro.cs):Macro持有名字、参数列表、variadic标志和展开 token 流,isFunctionLike()判断是否为函数式宏(参数列表非 null),getArgs()返回参数个数,addPaste/getText支持##粘贴操作符的字节码式表达。 #define指令的处理在 define() 中完成:- 支持函数式宏参数列表解析;
- 支持可变参数:遇到
...时调用m.setVariadic(true)并把__VA_ARGS__加入参数表,且校验"省略号必须在最后一个参数"; - 支持粘贴操作符
##:转换为M_PASTE内部 token; - 支持字符串化操作符
#:当#后紧跟一个形参名时,转换为M_STRINGtoken(记录参数索引)。
- 展开过程在 macro() 中实现:函数式宏会贪婪读取实参(正确处理嵌套括号与空实参),并校验"参数个数匹配",然后逐个
args[i].expand(this)展开后压入MacroTokenSource。三个内置宏__LINE__、__FILE__、__COUNTER__走特殊路径(Preprocessor.cs):分别压入一个FixedTokenSource,__COUNTER__每次展开自增counter。
条件编译与表达式求值
CppNet 完整支持#if、#ifdef、#ifndef、#elif、#else、#endif。其机制是:
- 每个
#if/#ifdef/#ifndef都push_state()压入一个 State(Preprocessor.cs),isActive()需要父状态与自身同时激活;#endif对应pop_state(),且会检测"没有#if的#endif"。 #if表达式求值由 expr() 完成,是标准的递归下降表达式解析:expr_priority(Preprocessor.cs)给出了完整的 C 运算符优先级表——/、%、*(11 级),+、-(10 级),<<、>>(9 级),<、>、<=、>=(8 级),==、!=(7 级),&(6 级),^(5 级),|(4 级),&&(3 级),||(2 级),三元?:(1 级),外加一元~、!、-与括号;支持整数、字符字面量,除零/模零会报错,还实现了三元表达式。defined(x)/defined x两种写法都支持(expr_token()),未定义标识符在条件表达式中默认按 0 处理,只有开启Warning.UNDEF才告警。#ifdef/#ifndef直接查询宏表macros.ContainsKey(text)决定分支激活(Preprocessor.cs)。
include / include_next / import 与虚拟文件系统
include 解析是 CppNet 与 Clang 兼容性的关键:
- 支持
#include "..."与#include <...>两种形式,token 类型分别是STRING与HEADER; - 解析顺序(include(String, int, string, bool, bool, bool)):带引号先查当前文件父目录 → quote include 路径 → 系统 include 路径 → frameworks 路径;找不到时报
File not found: <name>并列出所有已搜索路径; - frameworks 查找由 includeFramework() 实现:把
FrameworkName/Header.h拼成FrameworkName.framework/Headers/Header.h再逐路径搜索; #include_next受Feature.INCLUDENEXT门控(Preprocessor.cs),未开启时报Directive include_next not enabled;#import由 import() 转调 include,并通过_importedPaths列表去重——同一个文件只导入一次(Preprocessor.cs);- 所有路径解析都经过
VirtualFileSystem(默认JavaFileSystem),通过setFileSystem(VirtualFileSystem)可以整体替换为自定义实现(如内存文件系统)。
预处理器的指令分发表ppcmds(Preprocessor.cs)共登记 15 个指令:define、elif、else、endif、error、if、ifdef、ifndef、include、line、pragma、undef、warning、include_next、import。未知指令会直接报错Unknown preprocessor directive。
__has_include / __has_include_next / __has_feature:面向 Clang 的扩展
这是 CppNet 相对 JCPP 最重要的增强,全部作用于#if条件表达式:
__has_include("x")/__has_include(<x>):由 has_include(false) 实现——它以checkOnly=true模式走完整的 include 搜索逻辑(但不实际压栈文件),找到返回 1,否则 0;__has_include_next(...):同样的逻辑,has_include(true),从下一个搜索路径开始;__has_feature(feature):由 has_feature() 实现,内部是一个巨大的 Clang 特性名switch,覆盖objc_arc、objc_bool、blocks、cxx_lambdas、cxx_constexpr、cxx_rtti、cxx_variadic_templates、各类 sanitizer(address_sanitizer/thread_sanitizer等)以及 C11 特性(c_alignas、c_atomic等)上百个特性名,命中返回 1,未知特性返回 0。
在表达式求值路径 expr() 中,这三个标识符会被识别并按上述逻辑折叠成整型常量 0/1。此外在defined(...)判定里,__has_include、__has_include_next、__has_feature也被视为"已定义"(Preprocessor.cs),兼容 SDK 头文件里#if defined(__has_include)的惯用写法。
监听器与错误处理
PreprocessorListener(PreprocessorListener.cs)是一个三方法接口:
handleWarning(Source source, int line, int column, string msg);handleError(Source source, int line, int column, string msg);handleSourceChange(Source source, string ev)——source 压栈/弹栈时触发(事件值为"suspend"、"push"、"pop"、"resume",见 push_source/pop_source)。
接口注释明确说明:如果没有安装监听器,所有错误与警告都会以异常形式抛出(PreprocessorListener.cs);安装监听器后才能实现"记录日志继续运行"这类更智能的处理。仓库还提供了DefaultPreprocessorListener作为默认实现。#error与#warning指令也走这套机制(error(Token, bool))。
行标记(Linemarkers)
当开启Feature.LINEMARKERS时,CppNet 会在 include 进入/返回处输出形如# linenum filename flags的行标记 token(Token.P_LINE),由 line_token() 生成;EmitExtraLineInfo属性(默认 true)控制是否附加额外标志。源码注释(Preprocessor.cs)说明了四个标志的含义:
1:进入新文件;2:从被包含文件返回;3:后续文本来自系统头文件,应抑制部分警告;4:后续文本应被当作隐式extern "C"块。
在 Stride 中的真实应用:SDSL 着色器预处理
CppNet 在 Stride 中不是孤立依赖,而是服务于 SDSL(Stride 着色器语言)的预处理环节。入口位于 MacroPreProcessor.cs,其中静态类MonoGamePreProcessor提供了两个方法:
OpenAndRun(string filepath, params ReadOnlySpan<(string Name, string Definition)> defines):读取文件后转调Run;Run(string content, string? filename, params ReadOnlySpan<(string Name, string Definition)> defines):核心逻辑。
其关键调用序列与 README 示例高度一致:
var cpp = new Preprocessor(); cpp.addFeature(Feature.DIGRAPHS); cpp.addWarning(Warning.IMPORT); cpp.addFeature(Feature.INCLUDENEXT); // 透传 defines foreach (var (Name, Definition) in defines) { if (!string.IsNullOrWhiteSpace(Name)) cpp.addMacro(Name, Definition ?? string.Empty); } var inputSource = new StringLexerSource(content, true, filename!); cpp.addInput(inputSource); // 循环读取 token,重建文本 while (!isEndOfStream) { Token tok = cpp.token(); switch (tok.getType()) { case Token.EOF: isEndOfStream = true; break; case Token.CCOMMENT: // 逐字符替换为空格(保留换行) case Token.CPPCOMMENT: // 直接丢弃 default: textBuilder.Append(tok.getText()); } }这个封装展示了 CppNet 的标准消费方式:以字符串为输入源(StringLexerSource),逐个取出 token,把块注释替换为等长空格(保证行列号不变)、丢弃行注释、其余文本按getText()拼接,最终得到预处理后的源码文本。这正是 Shader 编译器在进入语法分析前对.sdsl进行宏展开、条件编译与#include展开的实际路径。其底层对应的Token类型定义见 Token.cs,词法入口见 LexerSource.cs。
从仓库获取与集成方式
- 直接引用预编译 DLL:仓库已在 deps/CppNet/netstandard1.3/ 提供了 netstandard1.3 版本的
CppNet.dll(含 PDB),适合外部项目直接引用;deps/CppNet/checkout.bat是依赖获取脚本。 - 使用 vendored 源码:需要把 sources/shaders/Stride.Shaders.Parsers/CppNet/ 目录下的源文件加入编译单元。注意此副本为 MonoGame 管线做过 API 内部化(类型均为
internal),Stride 正是通过MonoGamePreProcessor间接使用,若需对外暴露 API 请自行调整可见性。 - 许可证:遵循 Apache License 2.0,见 deps/CppNet/LICENSE。
CppNet 的完整指令集、宏展开语义、条件求值优先级与 Clang 扩展均已在此文中结合源码逐项核实;如需深入阅读实现细节,Preprocessor.cs 的 2200 余行代码是最权威的参考。
- 游戏开发
- 图形学
- VR
【免费下载链接】stride
Stride (formerly Xenko), a free and open-source cross-platform C# game engine.
相关推荐
C++宏定义管理:vscode-cpptools预处理器配置
C++宏定义管理:vscode cpptools预处理器配置 引言:宏定义管理的痛点与解决方案 你是否曾在大型C++项目中遭遇过宏定义冲突导致的编译错误?是否因
开发工具调试器VSCode C/C++扩展中预处理宏高亮问题的分析与解决
VSCode C/C++扩展中预处理宏高亮问题的分析与解决 在VSCode的C/C++开发环境中,预处理宏的高亮显示是一个重要的代码可视化功能。本文深入分析了该
开发工具调试器SAM 自定义训练实战:一条把 segment-anything 微调进业务数据的完整路线图
SAM 自定义训练实战:一条把 segment anything 微调进业务数据的完整路线图 通用 SAM 在街景和宠物照片上很好用,换到你的医疗影像、工业缺陷
人工智能计算机视觉基础模型
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考