从 LM-63 到实时预览:Unity IES 光度文件编辑器

IES 文件保存的不是一张灯光纹理,而是灯具在不同空间方向上的光强分布。这个工具的目标,是让技术美术和灯光美术无需离开 Unity,就能读取 IESNA LM-63、检查光度曲线、观察真实落地形状、编辑能量分布,并把结果重新保存成可被其他渲染器继续使用的标准文件。

Unity IES 光度文件预览与高级曲线编辑界面
工具同时展示光度学数据与可编辑曲线。所有截图均来自独立测试资源,不包含项目场景、业务资产或内部路径。
IESNA LM-63文本解析与标准回写
C-γ Table二维坎德拉分布
2D + 3D曲线、投影与空间预览
Editor Only不引入运行时成本

问题:一份 IES 文件为什么难以直接判断

传统的纹理预览适合颜色和法线,却不适合 IES。IES 记录的是光强 I(C, γ)γ 表示相对灯具光轴的垂直角,C 表示绕光轴旋转的水平角。一个值的单位是 candela,它描述“向这个方向发出多强的光”,而不是屏幕上的最终亮度。

因此,工程里真正需要回答的是三类问题:文件是否有效,光束在角度域里是什么形状,以及它打到真实表面后会形成怎样的照度。只有极坐标图会缺少空间直觉;只把 IES 挂到场景灯光上,又会被曝光、材质、天气和渲染管线干扰。工具把这三层拆开,分别提供数据、物理投影和独立 3D 预览。

LM-63 TextHeader / TILT / Angles / Candela

IESDataC-γ 二维光强表

PreviewPolar / Surface / 3D

EditScale / Spread / Shift / Curve

Serialize重算峰值与光通量

第一层:把 LM-63 解析成可工作的光度数据

解析器先读取版本头与方括号关键字,直到遇到 TILT=;随后把换行、空格、制表符和逗号统一视为数值分隔符。这样可以兼容不同厂商导出时的折行差异。数值读取使用 InvariantCulture,避免编辑器系统语言改变小数点语义。

数据段 主要字段 工具中的用途
Header / Keywords LM-63 版本、灯具名称、厂商描述 资产识别与保存时保留元数据
Photometric header 灯数、流明、倍率、角度数量、Type、单位和灯具尺寸 校验文件、显示摘要、确定数组尺寸
Vertical angles γ[0..V-1] 描述离开光轴的采样角
Horizontal angles C[0..H-1] 描述绕光轴的采样平面与对称性
Candela table cd[H,V] × multiplier 预览、编辑与保存的核心数据

读取坎德拉表时,原始 multiplier 会立即乘进每个样本,内部始终使用绝对光强。同时遍历整张表得到峰值 candela 及对应的 (C, γ),后续的归一化、曝光参考和光束角计算都依赖这一组统计数据。

TILT 的处理边界

解析器能够跳过文件内嵌的 TILT 数据,使预览不会因额外数值段错位;但编辑和保存只允许 TILT=NONE。这是刻意的防御性约束:没有完整实现倾斜灯具的变换语义前,不应该写出看似合法、实际方向错误的文件。

第二层:极坐标图把“光往哪里走”画出来

极坐标图以灯具光轴为 0°,绘制 C0 和 C90 两个典型截面。半径表示光强,角度表示 γ。线性模式适合比较绝对能量,Log 模式能把弱尾部拉出来检查;Normalize 则让不同功率的灯具可以只比较光束形状。

IES C0 与 C90 平面的极坐标光度曲线和元数据
极坐标曲线与文件摘要并排显示。示例为轴对称分布,因此 C0 与 C90 重合;工具仍明确标记两个光度平面的语义。

Beam Angle 和 Field Angle 不是额外存储的字段,而是从峰值向两侧寻找阈值交点得到的派生指标。工具分别使用峰值的 50% 和 10% 作为阈值,用于快速判断主光束宽度与外围有效范围。它们适合资产筛选,但不能替代完整曲线,因为两个光束角相同的灯具仍可能拥有完全不同的肩部和尾部。

第三层:从坎德拉到表面照度

为了回答“打到地面或墙上是什么样”,工具提供纯 CPU 的表面投影。每个像素先从灯具位置构造方向,换算为 (C, γ),再在坎德拉表中做双线性插值。最终照度遵循点光源的基本关系:

E(p) = I(C, γ) · max(0, N · L) / r²

其中 I 是该方向的光强,N · L 是表面入射余弦, 是平方反比衰减。预览再用峰值和灯距做自动曝光,并经过指数型 tone mapping 与 sRGB 转换。这样既保留光束形状,又不会让不同量级的 IES 在编辑器里直接全白或全黑。

对称性展开是正确采样的关键

IES 文件为了节省数据,常常只保存部分水平角:一行代表轴对称,0° 到 90° 代表四象限对称,0° 到 180° 代表双边对称,接近完整一圈则按全方向数据处理。采样任意 C 角时,工具先按文件声明隐含的对称性折叠到已存区间,再在 C 和 γ 两个方向插值。忽略这一步,预览会出现缺扇区、断边或左右不一致。

第四层:不依赖渲染管线的 3D 实时预览

场景预览没有创建 Unity Light,也不要求 HDRP 的 IES Profile。CPU 先把分布烘焙为一张 360 × 181RGBAHalf 查找纹理,U 对应 C 角 0° 到 360°,V 对应 γ 角 0° 到 180°,R 通道保存相对峰值归一化的光强。

IES 灯具位置方向与地面照明的交互式三维预览
灯具位置、Yaw、Pitch 和曝光可以实时调整;相机支持环绕、缩放和 FOV 修改。预览关注光斑方向与空间尺度,而不是复刻某条项目渲染管线。

GPU 侧通过一次 Graphics.Blit 执行全屏 ray caster。每个像素构造相机射线,与地面平面求交,在灯具局部基底中恢复 C 和 γ,再查询 IES LUT 并计算 I · cosθ / r²。地面网格只用于提供尺度与透视参照。因为整个过程只依赖一个隐藏 Shader 和一张 512² RenderTexture,所以 Built-in、URP 与 HDRP 编辑器环境都能使用同一套预览。

为什么不直接复用项目灯光

独立预览把 IES 本身与场景变量隔离开。它不会受到曝光卷积、阴影、材质 BRDF、体积雾或天气系统影响,因此非常适合判断“文件是否正确”。最终落地仍需回到目标渲染管线验收,两者解决的是不同问题。

编辑:修改的是能量分布,不是灯的 Transform

编辑器始终保留一份 Source,并在每次参数变化时深拷贝出 Edited Snapshot。所有操作都作用于坎德拉表,因此保存后的光束形状可以被其他支持 IES 的软件复现,而不是只在当前 Unity 场景里生效。

IES 基础编辑参数,包括亮度、展宽和二维光束偏移
基础编辑面向高频操作:整体亮度、光束展宽,以及不移动灯具 Transform 的前后/左右光束偏移。
参数 实现方式 结果
Brightness 所有采样乘以全局 Scale Factor 保持形状,改变绝对 candela
Beam Spread 按比例反向重采样 γ 角距离 大于 1 展宽,小于 1 收窄
Push Forward / Sideways 把输出方向旋转回源分布后采样 光轴不动,能量峰值在 IES 内偏移
γ Remap 按垂直角应用 AnimationCurve 倍率 独立调整中心、肩部与远端能量
C0 / C90 Freehand 用归一化曲线覆盖指定光度平面 直接塑造两个关键截面

二维光束偏移是其中最容易被低估的一步。工具先把目标偏移量组合成一个峰值方向,再通过 Quaternion 构造“目标峰值到原始光轴”的旋转。对输出表中的每个方向,先做逆向方向映射,再回到源表双线性采样。这样避免直接平移数组造成角度空间变形。

轴对称文件只有一行水平角,无法表达偏向某个 C 平面的光束。一旦发生非零二维偏移,工具会把它扩展为 0° 到 360°、每 10° 一行的完整表,再进行方向重采样。这个扩展会增加文件数据量,却是打破轴对称后保持信息完整所必须付出的成本。

IES 高级 γ 重映射与 C0 C90 自由曲线编辑
高级模式允许按 γ 角做乘法重映射,并直接编辑 C0/C90 曲线。界面根据轴对称、四象限、双边或全方向数据自动限制不可表达的通道。

保存:不只把数组写回文本

序列化器保留原有关键字,把内部的绝对 candela 写回文件,并强制 multiplier 为 1,避免“数组已经乘过倍率、文件又重复乘一次”的二次放大。保存时还可以追加一条编辑历史说明;界面默认提供 Save As,并把覆盖原文件放在明确的危险操作中。

编辑后的总光通量通过球面积分重新估计:

Φ = ∫₀²π ∫₀π I(γ, C) · sin(γ) dγ dC

离散实现沿相邻 γ 区间积分,并对已存 C 范围求平均,再利用轴对称、四象限或双边对称扩展到完整 2π。结果乘以 Ballast Factor,写回 Lumens Per Lamp。峰值 candela 与峰值角度也会重新遍历计算,保证文件摘要与编辑后数据一致。

模块拆分与职责

模块 职责 设计价值
Parser / IESData LM-63 读取、字段归一化、峰值统计 让所有视图共享同一份明确数据模型
Plot Renderer C0/C90 极坐标曲线、线性/对数显示 快速检查角度域分布
Light Projector 对称性映射、双线性采样、地面/墙面照度 提供不依赖场景的物理形状参考
Scene Previewer + Shader LUT、全屏射线、相机和灯具交互 跨管线提供实时空间反馈
Editor 非破坏重采样、曲线覆盖、对称性扩展 把艺术控制落到标准光度数据
Serializer 元数据保留、流明积分、LM-63 回写 形成可交付、可复用的闭环

性能与工程边界

这是 Editor-only 工具,不进入 Player,也不会给游戏运行时增加 Pass、Draw Call 或常驻纹理。解析与编辑的主要复杂度是 O(H × V);默认 LUT 约 6.5 万个采样,3D 预览是一张固定 512² RT 的全屏 Pass。CPU 表面投影默认 256²,最大限制为 1024²,只有重建时发生。

边界 当前策略 后续可扩展方向
TILT 非 NONE 允许安全读取,禁止编辑保存 实现完整倾斜几何与外部 TILT 文件解析
Photometric Type A/B 保留数据;空间预览按常用 Type C 语义解释 加入 A/B 到 Type C 的坐标转换
异常水平角表 标准 0/90/180/360 对称;其他范围钳制 按 LM-63 边界情况增加更严格校验
高分辨率 CPU 投影 同步生成并限制分辨率 编辑防抖、Jobs/Burst 或 GPU Compute
最终画面一致性 独立曝光与简化地面模型 增加目标管线适配层与基准场景对照

结论

这套工具的价值不在于多做了一个 IES 缩略图,而在于建立了一条完整、可验证的数据链:从标准文件读取方向光强,经过对称性正确的采样与物理照度预览,再以非破坏方式重塑分布,最后重算派生量并写回标准格式。它把灯光资产从“只能导入后试效果”的黑盒,变成可以检查、比较、编辑和交付的数据资产。

对生产工具而言,最重要的并不是把所有边界一次性覆盖,而是明确哪些数据已经被正确解释、哪些操作会改变物理量、哪些格式暂时只读。正是这些约束,让实时反馈不会以牺牲文件可靠性为代价。

项目级车漆:从资产对标、HDRP StackLit 到全量延迟

这不是一次“把车漆调亮”的材质练习,而是一次从通用 Shading Model 到项目生产 Shader 的完整落地。我保留 Unity HDRP StackLit 的 VLayered BSDF 核心,围绕车辆资产重新构建材质输入、前向光照接入、反射控制、损伤与雨水模块以及自定义 Shader GUI;随后又把同一套车漆推入全量延迟路径,验证复杂分层材质在 GBuffer 中需要付出什么代价。

Unity 2022.3自研 HDRP 分支
3-LayerBase / Flake / Clear Coat
Forward + Deferred两条完整光照路径
4 × RGBA16全量延迟扩展 GBuffer
项目车辆展示场景中的最终车漆效果
最终延迟版本。高光层、环境反射、几何法线与 SSR 已恢复到接近前向参考的表现。

问题不是“亮不亮”,而是层次是否成立

真实车漆的识别度来自多个尺度同时成立:底漆决定颜色和大尺度反射,金属颗粒打碎近景高光,最外层清漆提供更锐利的第二套法线与 Fresnel。只提高 Smoothness 会得到像塑料一样的单层亮面;简单叠加两个 Specular,又会在高亮区域破坏能量关系。

HDRP StackLit 已经提供了适合这个问题的物理骨架,但原版是面向通用材质系统设计的。项目真正需要的不是把全部 StackLit 选项搬进一个面板,而是确定视觉核心、建立资产约定,并让 Shader 能进入车辆灯光、损伤、天气、SSR 和渲染管线。

先做横向拆解:成品车漆依赖哪些输入

在决定 Shader 功能之前,我先对《异环》《The Crew 2》《Cyberpunk 2077》和《Forza Horizon 5》的公开运行画面与调试截帧做了横向观察。这里必须区分“看见的数据”和“推断的实现”:截帧可以确认某类贴图、遮罩或材质层存在,却不能证明对方使用了某一段具体 BRDF 代码。对标的价值不是复刻别人的参数,而是建立资产需求清单。

两组参考车辆在不同清漆遮罩与光滑度下的高光和反射对比
参考画面的 Coat 扫描。高光形状与环境反射是两个相关但不能混为一个滑杆的视觉维度。
参考对象 截帧中可确认的输入 对项目方案的启发
《异环》 车身法线、Base Color、Emission、Flake Normal、损伤、Cubemap 与打包 Mask 车漆不是单个颜色参数,基础几何细节、颗粒、局部状态和环境响应必须分层
《The Crew 2》 车身法线、损伤、脏迹法线、碳纤维与局部 Mesh/Mask 不同部件需要共享主模型,同时允许局部材质和损伤通道独立
《Cyberpunk 2077》 运行画面中的多材质分区、表面磨损与局部覆盖 视觉复杂度更多来自区域语义和状态叠加,不应只依赖更复杂的高光公式
《Forza Horizon 5》 Damage、Flake Normal、多层脏迹、Cubemap、Normal 与水迹方向 高质量车辆材质需要把“干净车漆”和天气/损伤输入设计成同一数据协议
参考车辆材质中的基础法线、底色、金属颗粒、损伤、环境立方体和打包遮罩
参考资产输入节选。截图用于功能归类,不把画面中的通道布局直接当作对方引擎的最终材质协议。

这轮拆解直接改变了实现优先级:先保证 Base/Normal/Mask、Flake、Coat、Damage/Weather 和 Reflection 六类数据有稳定入口,再讨论更昂贵的波瓣或延迟编码。它也暴露了一个常见误区:环境反射质量高度依赖 Cubemap、Probe、SSR、GI 与场景曝光,不能把所有差异都归因到车漆 Shader。

03Clear Coat

独立光滑度、IOR、厚度、消光色和法线;控制锐利高光与掠射反射。

02Flake / Pigment

高频法线扰动与副波瓣,负责近景颗粒和高光破碎。

01Base Coat

底色、金属度、AO、主法线和主波瓣,提供稳定的大尺度材质形体。

先拆原版 StackLit:数据如何走到光照

我没有重写 StackLit 的主体 BRDF。自定义版本与项目内原版 StackLit.hlsl 仍保持绝大部分代码一致,核心工作是理解并保留它的数据契约。阅读顺序不是从四千多行文件的第一行一路向下,而是沿一条稳定的数据流:

Material Inputs贴图与参数

SurfaceData材质语义

BSDFData光照参数

PreLightData视角相关预计算

LightLoopDirect / SSR / IBL

双波瓣不是“两次高光相加”

StackLit 的底层可以同时持有两组 GGX 波瓣。每组波瓣有自己的粗糙度,最后通过 lobeMix 混合。开启各向异性时,标量粗糙度会进一步展开为切线和副切线方向的粗糙度。它解决的是同一底层材料中宽高光与窄高光共存的问题,而不是清漆层。

baseSpecular = NdotL * bottomF
             * lerp(DV_LobeA * energyA,
                    DV_LobeB * energyB,
                    lobeMix);

Clear Coat 使用 VLayering 处理能量

清漆位于底层上方。StackLit 的 ComputeAdding 会根据 Coat IOR、厚度、消光和观察角度,把顶层反射、底层透射以及等效粗糙度组合进 PreLightData。直接光阶段最终同时计算底层波瓣与 Coat 波瓣,但 Coat 不只是额外加亮,它还会改变进入底层的能量。

coatSpecular = coatNdotL
             * topLayerEnergy
             * coatEnergyCompensation
             * coatDV;

finalSpecular = baseSpecular + coatSpecular;

这也是车漆不能简单退回普通 Lit Clear Coat 的原因:这里不仅有独立 Coat,还要保留双底层波瓣、各向异性、HazyGloss、独立法线以及 SSR/IBL 的逐波瓣权重。

受控矩阵:为什么 Metallic + Coat 暗部更敏感

文档里最有价值的实验,是在固定模型、灯光、曝光和相机后,把 MetallicCoatMask 分别按 0、0.5、0.75、1 扫描,并单独观察 Diffuse、Specular、Reflection 与 GI。它把“暗部发黑”从主观观感拆成了可定位的数据链。

Metallic 与 CoatMask 参数矩阵下的漫反射、高光、环境反射和烘焙 GI 分量
同一组 Metallic × CoatMask 参数分别查看四个光照分量。左上角是低 Metallic/低 Coat,右下角是高 Metallic/高 Coat。

源码中的关系可以简化为三步。首先,金属工作流让 diffuseColor 随 Metallic 增大而趋近于零;其次,VLayering 在 ComputeAdding 中通过 lerp(1, Ti0, coatMask) 计算底层漫反射能量;最后,烘焙漫反射会继续乘上 diffuseFGD × diffuseEnergy,GTAO 的多次反弹着色也使用经过这层能量处理的漫反射颜色。当 Metallic 和 CoatMask 同时接近 1,而 Probe/SSR/GI 又不足以补回镜面环境能量时,暗部自然会非常深。

这不是简单的“模型错误”

StackLit 在表达多层介质的能量关系,结果是否好看取决于材质参数与环境光照是否匹配。线性地把 Lit、UE Clear Coat、StackLit、Substrate 排成“物理精确度排行榜”过于武断,因此我没有沿用原文那张排序图;更可靠的结论是比较每个模型的参数语义、层间透射近似和项目约束。

现象 优先排查 工程处理
正面暗、掠射角正常 Coat Fresnel、正面反射 Remap、曝光 先校准 Probe/SSR,再限定正面反射下限
高 Metallic 区域接近纯黑 Base Color/F0、环境反射覆盖、GI 保证金属区有可反射环境,不用漫反射补假亮度
Coat 开启后整体压暗 diffuseEnergy、Coat IOR/Mask、Extinction 用受控矩阵确定能量缩放范围,并锁定危险组合
缝隙或接触区脏黑 SSAO/GTAO、几何法线、Diffuse tint base 分离查看 AO 与 Beauty,避免 AO 在近零底色上二次压黑

从通用模型到项目级 CarPaint

原版 StackLit 负责“光应该怎么计算”,项目 CarPaint 负责“车辆资产如何稳定地提供这些数据”。我把这部分拆成独立的 CarPaint_Input.hlsl、Forward Pass、Depth/Motion Pass、损伤和雨水模块,再将结果送入 StackLit 的 ConvertSurfaceDataToBSDFData 与 LightLoop。

项目模块 输入或处理 进入 StackLit 的结果
Base / Mask BaseColor、Metallic、AO、Smoothness 通道选择与 Remap 稳定的底层颜色、F0 与粗糙度
Dual Lobe / HazyGloss Smoothness A/B、LobeMix,或 Haziness/HazeExtent 两组底层 GGX 波瓣
Flake 高频法线、独立缩放和强度 打碎底层高光,不改变几何轮廓
Clear Coat Mask、Smoothness、IOR、Thickness、Extinction、独立 Normal VLayered 顶层界面
Reflection Tuning 直接光高光大小/强度/颜色、IBL 粗糙度解耦、正面/掠射 Remap 在物理骨架上提供车辆美术控制
Damage / Weather 8 区域损伤、划痕、泥点、灰尘、雨痕与水滴 联动颜色、法线、AO、Smoothness 与 Coat 移除
Vehicle Lighting 车辆专用灯光倍率、四通道车灯 Emission 接入项目灯光分层和运行时状态

前向路径:直接保留完整材质状态

前向版本是这套 Shader 最直接、数据最完整的实现。Fragment 阶段构建 SurfaceData,转换为 BSDFData,采样 Baked GI,再生成 PreLightData。随后我在进入 LightLoop 前对 Coat 做项目级调制:

  • _CoatSpecularSize 调整直接光 Coat 波瓣粗糙度。
  • _CoatSpecularIntensity × _CoatSpecularColor 调整顶层直接光能量。
  • _CoatReflectionMin / Max 重映射 Coat 的环境反射响应。
  • Reflection Override 将直接光 Smoothness 与 IBL/SSR 使用的反射粗糙度解耦。
  • 车辆灯光倍率在 Punctual 与 Area Light 回调中逐灯生效。
CarPaint 前向渲染参考效果
前向参考。材质常量直接参与 PreLightData 和 LightLoop,不需要经过 GBuffer 量化与重建。

Shader GUI 是生产系统的一部分

复杂 Shader 如果只提供一长列属性,很快会失去可维护性。配套的 CustomShaderGUI_CarPaint 按材质职责组织折叠面板,并由 Toggle 同步 Shader Keyword。常用参数直接暴露,容易破坏资产一致性的高级参数默认锁定,同时提供一键恢复默认值。

基础与车漆颜色法线与 Mask双波瓣高光清漆层Flake反射与 Fresnel车灯 Emission区域材质损伤与污渍雨水破碎玻璃渲染状态

GUI 里最重要的并不是“参数多”,而是把依赖关系说清楚。例如启用 HazyGloss 后,Smoothness B 与 LobeMix 不再由美术直接填写;启用 Reflection Override 后,面板明确区分“直接光 Coat 光滑度”和“环境反射清晰度”;损伤模块则按固定区域 ID 管理局部状态,避免运行时为每个部件复制材质。

参数矩阵把 GUI 约束变成资产规则

单张 Beauty 很难判断参数是否正交,所以我又建立了球阵列测试:分别扫描 Base Smoothness、Metallic、CoatMask、CoatSmoothness、Coat IOR、Thickness 与 Extinction。矩阵的作用不是展示“参数很多”,而是找出哪些参数可以交给美术自由调整,哪些必须成组出现,哪些应该被预设锁定。

Lit 与 StackLit 以及清漆遮罩、金属度、光滑度、折射率、厚度和消光参数矩阵
参数矩阵节选。每组测试只改变标题中的变量,避免场景曝光和灯光变化掩盖材质响应。
参数组 观察结论 GUI 策略
Metallic / Base Smoothness Coat 关闭时遵循常规金属度 PBR 语义 作为基础材质参数直接开放,并提供贴图通道 Remap
CoatMask / CoatSmoothness 前者控制顶层参与度,后者控制顶层波瓣宽度,两者不能合并 常用区开放,但用预设限定有效范围
Coat IOR / CoatSmoothness IOR 同时改变 F0 与角度响应,不只是“高光强度” 归入高级参数,默认锁定在材质类别预设中
Thickness / Extinction 两者共同决定颜色吸收;厚度接近零时 Extinction 基本失去意义 成组显示、联动启用,避免单独调色造成不可解释结果
Environment / Reflection 决定暗部是否有可信的镜面信息,但主体数据来自场景 材质仅保留必要的粗糙度与范围调制,不用局部 Cubemap 掩盖环境缺失

最终资产规则因此比“提供一套默认材质球”更具体:车身至少要有可用的 Base/Normal/Mask,近景车型补 Flake Normal,需要状态变化时再接 Damage、Dirt 与 Water;Coat IOR、Thickness 和 Extinction 由材质类别预设管理。这样既保留 StackLit 的物理层次,也避免每辆车都从几十个自由参数重新试错。

为什么继续尝试全量延迟

前向效果已经成立,但项目场景主体走 Deferred,车漆需要更自然地接入统一的深度、法线、SSR、灯光分层与调试链路。问题是普通 HDRP GBuffer 只为 Lit 的材质模型设计,无法完整表达 StackLit 的双波瓣、VLayer Coat、几何法线、各向异性和项目反射控制。

因此我没有让延迟版本使用一套简化 BRDF,而是要求它恢复出与前向相同的 BSDFData 和 PreLightData。流程变成:

Depth / Stencil可见性与材质标记

HDRP GBuffer公共 Lit 数据

CarPaint Data4 张扩展 RT

Decode重建 BSDFData

StackLit LightLoop同一套光照核心

四张扩展 GBuffer 保存什么

CarPaintGBufferData 是一个独立几何 Pass,在公共 GBuffer 之后写入四张全分辨率 R16G16B16A16_UNorm。使用独立 Pass 是因为 HDRP MRT 还可能被 VT Feedback、Light Layers 和 Shadow Mask 占用,无法稳定追加完整车漆数据。

RT 主要内容 设计目的
CarPaint0 Base Normal、Roughness A/B、Tangent Angle 恢复双波瓣与各向异性坐标系
CarPaint1 Geometry Normal、Lobe/Hazy、Coat IOR、Anisotropy 恢复 VLayering、几何约束与功能分支
CarPaint2 Coat 高光控制、反射范围、Reflection Override、能量与厚度 恢复项目级 PreLightData 调制
CarPaint3 Coat Specular RGB、Extinction RGB、Env Grayscale 恢复完整顶层颜色和介质衰减

每个 16-bit 通道进一步保存两个独立 8-bit 数据。法线使用 Octahedral 编码,切线压缩为相对法线局部坐标系中的一个角度;HazyGloss 则复用在该模式下本来失活的 Lobe B 槽位。这里的目标不是追求极限压缩,而是先建立一份能够与前向逐项核对的“全量基线”。

效果对齐不是一次完成的

第一版延迟虽然能正确受光,但反射明显弱于前向,几何法线也没有真正参与最终层状计算。原因不在单一公式,而在多个阶段共同作用:Coat FGD 被项目反射重映射后又被 SSR 使用、SSR Alpha 同时承担反射层级权重、fullscreen shader 的功能全集改变了法线数组布局,而一些前向材质常量没有完整进入 GBuffer。

初版全量延迟车漆效果
初版 Deferred:反射层次与高光能量不足。
同场景前向车漆参考
Forward Reference:Coat 和环境反射更完整。

最终修正包括:保留 SSR 使用的原始 Coat FGD、恢复 Geometry Normal 与 Coat/Base Normal 的正确槽位、让弱 SSR 不再完全吞掉 Env/Sky/Probe、统一 SSR 高亮压缩,并将前向的 Coat Specular、Extinction、反射粗糙度与能量参数完整编码。

一次典型的跨阶段 Bug:白色为什么变成紫色

最终阶段出现过一个非常有代表性的错误:_CoatSpecularColor 明明是白色,移动灯光时 Coat 高光却呈现紫色。修改 Coat 颜色会改变紫点,因此问题一定在 Coat 直接光链路,但材质常量本身没有偏色。

错误字节打包导致的紫色 Coat 高光
错误的 byte-pair 打包污染了 RGB 通道,紫色高光随后又进入下一帧 SSR Color Pyramid。
根因

公共 Pack2Byte 在高位值仍带小数时先乘 256,最后才取整。对 Coat 白色而言,1.0 / 8.0 应打包为字节 (32, 32),实际却形成 (32, 0)。RGB 解码结果约为 (1.004, 0, 1.004),正好呈紫色。

修复方式是在移位之前先分别把两个值量化到 0–255,再组合为精确 16-bit 整数,并使用 R16_UNorm 保存。GPU 回读验证白色为 (32,32,32),0.8 灰色为 (26,26,26)。这个问题也说明:Deferred 的调试不能只看最终 Beauty,必须能检查 GBuffer 原值、解码值、PreLightData 和 SSR 输入。

Forward 与全量 Deferred 的真实取舍

维度 Forward 全量 Deferred
数据精度 材质常量直接进入 BSDF,无额外量化 复杂参数需要编码、量化和重建
效果一致性 基准实现,透明和特殊功能自然接入 修正后接近前向,但 SSR 层级与量化仍非数学等价
几何与材质成本 一次主要材质计算与 LightLoop 公共 GBuffer、额外数据 Pass、fullscreen lighting
显存与带宽 不需要车漆专用 RT 额外 4 × RGBA16,即 32 Byte/Pixel
管线集成 需要维护 Forward 对公共深度/法线的写入 更容易接入统一 Deferred、SSR 与屏幕空间调试
扩展维护 增加参数后可直接读取 每个新参数都要重新审视 GBuffer 协议

以 1920×1080 计算,四张扩展 RT 额外占用约 63.3 MiB;只算一次写入和一次读取,理论流量约 126.6 MiB/帧。4K 下分配约 253 MiB。实际带宽会受到车辆覆盖率、压缩和 GPU 架构影响,但额外几何 Pass 与全尺寸资源是真实存在的。

现代 HDRP Forward 已使用灯光列表,并不是传统的“每盏灯增加一次 DrawCall”。因此这次全量延迟的主要收益是统一数据链路和验证技术边界,而不是自动获得性能优势。对于当前车漆,Forward 仍然是性价比更高的生产路径;全量 Deferred 更适合作为可验证基线,下一步应尝试只保存公共 GBuffer 无法重建的数据,将四张扩展 RT 收敛到一到两张。

这项工作的最终价值

这个案例的重点不是“使用了 StackLit”,而是把一个通用、庞大的物理材质模型变成可由车辆资产稳定使用的生产系统。我完成了从源码阅读、材质语义、前向 LightLoop、反射层级、项目灯光,到 GUI、损伤、天气和全量延迟实验的整条链路。

工程结论

保留经过验证的 StackLit VLayering 核心,把项目差异集中在 SurfaceData、PreLightData 与渲染管线边界;先建立效果正确的 Forward 基准,再用全量 Deferred 暴露数据需求,最后根据真实性能决定是否压缩,而不是为了“走延迟”牺牲可解释性。

原始分析资料

25 页原始 PDF 保留了参考作品截帧、资产输入、源码思维导图、暗部能量拆解和材质参数矩阵。网页文章在此基础上校正了过度简化的模型排序,并补充项目 CarPaint 前向实现、Shader GUI、全量延迟设计与完整问题复盘。

打开原始 PDF:车漆技术分析

Unity中的移动平台RVT实现

谁在用Runtime Virtual Texture这个技术

· UBI在2016年公布了在Far Cry 4中使用的Adaptive Virtual Texture其中就包含Runtime virtual texture.

·UE在4.25版本发布了Runtime Virtual Texture

·鹅厂大佬公布天涯明月刀中使用了Runtime Virtual Texture,表示2层以上的混合就能表现出明显的性能优势

为什么需要Runtime Virtual Texture

  • 你要做任何的大型的景观,就绕不开使用Unity TerrainUE4 Landscape这样的多层混合材质巨型动态Mesh。
  • 从早年的world machine到现在流行的Houdini在地形制作这个部分都在疯狂地丰富一个东西—图层,8层,堪堪够用而已。

8层在十年前你可以认为是8张颜色贴图加两张控制图的混合,十张,好像是有点多,但是现在是PBR的时代,以Unity的Terrain材质为例8层意味着

(1 Albeto + 1 Normal + 1 Detail[AO R + Metal G + Roughness A])* 8 + 2 Control Map = 26次贴图采样

即每个像素,每帧都会有26次贴图采样。这就是为什么Unity官方推荐移动平台不要超过4层的原因。你以为4层就没事了吗?这个标准只能保证中端设备能跑30帧且烫不烫完全不care。

原理

Runtime Virtual Texture做的事情非常简单,把Terrain Mesh分块,把每块的26张贴图合并成2张render target,这样我们渲染的时候不管有多少图层到了渲染的时候都只有2个贴图采样,但是显存必然是有更大的开销的,空间换时间不多废话。

这里必须提出来一个问题,如果我们完全用静态资源,我们的资源到底有多大?以git工程的示例场景为例,一个传统的RPG视角,我们的LOD 0最小切割到8m * 8m,这样的一块在视角内要满足2k分辨率,需要至少1024分辨率的贴图,当然测试下来512也是可以忍的。对于一块512的Terrain,我们的贴图需求是 4096套1024贴图(albeto + normal + detail),这个资源量,再加上mipmap的开销

一、RVT核心原理

  • 虚拟纹理机制

RVT通过逻辑上的无限大纹理动态加载局部所需纹理,类似内存分页管理:

  1. Feedback阶段:低分辨率渲染获取当前视野所需纹理的mipmap等级和位置信息(RGB存储格子坐标与mip等级)
  2. PageTable管理:维护动态更新的页表,记录纹理加载状态和物理存储位置(LRU算法优化Tile复用)
  3. TileTexture烘焙:通过MRT(多渲染目标)实时烘焙Diffuse、法线等数据到物理贴图集
  • 移动端简化方案
  1. 四叉树动态分块:根据相机距离划分地形区块,每块分配相同尺寸贴图,用Texture2DArray存储(索引即数组下标),避免传统RVT的复杂空间管理
  2. 分帧加载:限制每帧细分/合并操作次数(如每帧最大细分4次),避免卡顿

二、关键实现步骤

  • 四叉树动态LOD管理
  1. 数据结构:节点记录坐标(x,z)、尺寸(size)、物理贴图索引(physicTexIndex)及父子节点引用。
  2. 合并与细分合并:当节点远离相机时,回收子节点贴图索引,用父节点低精度贴图覆盖。细分:靠近相机时分裂为4个子节点,分配新索引加载高精度贴图。
  3. 帧更新优化:每帧遍历叶节点队列,按LOD变化决策合并/细分,交换双队列避免GC
  • 物理贴图生成
  1. Blit替代相机渲染:避免传统相机渲染地形Mesh的开销,直接通过Shader一次性采样所有地表纹理(如16层)输出到Texture2DArray
  2. 索引贴图映射:根据地形UV生成索引贴图,存储节点起始坐标、尺寸、贴图数组索引,Shader采样时直接计算实际UV

三、移动端专项优化

1、纹理与内存管理

  • AlphaMap分组:每4种纹理共用一张AlphaMap,减少贴图切换(如12种纹理需3张图)
  • 异步加载:通过AsyncGPUReadback异步回读Feedback数据,延迟一帧使用避免阻塞

2、海洋主题适配

  • 岛屿独立节点:将岛屿抽象为独立四叉树节点,支持随机分布与紧密数据排列
  • 棋盘格烘焙:以512×512米为单位烘焙地形数据,适配航海类大世界

四、工程实践建议

1、开发管线集成

Houdini流程:用Houdini生成地形/道路/桥梁,导出Unity后通过HLOD System预处理(减面+合批),减少70%+ DrawCall

光照方案

  • 近距离:级联阴影(CSM)
  • 远距离:体素化阴影(VSM)+ LightProbe流式加载

2、调式工具

  • 可视化Feedback贴图与PageTable状态,实时监控Tile加载情况
  • 负反馈性能调节系统:动态限制高负载操作(如分帧细分)

其实吧RVT的核心架构一张图就能说清楚

与传统的贴图方案对比优势

优势场景:

  • 超大规模地形(如飞行模拟中的全球地表)
  • 程序化生成世界(无限地形动态烘焙)
  • 高清材质统一管理(避免重复资源)

下面我一步一步从前往后梳理整个过程,以及在Unity的实现技术细节。

格子

我们会根据可视范围把范围内的所有地块划分成格子,格子越小,分到固定分辨率大小的贴图清晰度就越高,当然性能也就越差。UE是固定格子,直接是以地形为单位划分的。但是对于一个超大世界来讲,这个范围是变化的,所以格子也是动态变化的。这里只交待一个概念,后面在各个部分的时候再详细讲。

FeedBack

feedback有点类似于遮挡剔除,会预先烘一个低分辨率的贴图信息,rgb分别表示格子坐标,mipmap等级。格子坐标通过世界坐标在可视区域,以及格子的大小这些参数就能算出来。mipmap等级则是通过偏导求出来的。



feed_v2f VTVertFeedback(feed_attr v)
{
    feed_v2f o;
    UNITY_SETUP_INSTANCE_ID(v);
    
#if defined(UNITY_INSTANCING_ENABLED)
    float2 patchVertex = v.vertex.xy;
    float4 instanceData = UNITY_ACCESS_INSTANCED_PROP(Terrain, _TerrainPatchInstanceData);

    float2 sampleCoords = (patchVertex.xy + instanceData.xy) * instanceData.z; // (xy + float2(xBase,yBase)) * skipScale
    float height = UnpackHeightmap(_TerrainHeightmapTexture.Load(int3(sampleCoords, 0)));

    v.vertex.xz = sampleCoords * _TerrainHeightmapScale.xz;
    v.vertex.y = height * _TerrainHeightmapScale.y;
    
    v.texcoord = sampleCoords * _TerrainHeightmapRecipSize.zw;
#endif
    
    VertexPositionInputs Attributes = GetVertexPositionInputs(v.vertex.xyz);
    
    o.pos = Attributes.positionCS;
    float2 posWS = Attributes.positionWS.xz;
    o.uv = (posWS - _VTRealRect.xy) / _VTRealRect.zw;
    
    return o;
}

float4 VTFragFeedback(feed_v2f i) : SV_Target
{
	float2 page = floor(i.uv * _VTFeedbackParam.x);

	float2 uv = i.uv * _VTFeedbackParam.y;
	float2 dx = ddx(uv);
	float2 dy = ddy(uv);
	int mip = clamp(int(0.5 * log2(max(dot(dx, dx), dot(dy, dy))) + 0.5 + _VTFeedbackParam.w), 0, _VTFeedbackParam.z);

	return float4(page / 255.0, mip / 255.0, 1);
}

在unity urp配置渲染管线,只需要添加一个renderdata,配置好参数

然后在地形里边添加一个feedback的pass

然后在feedback相机上选择这个render

feedback的相机要保证跟场景相机参数一样,可以写个脚本复制参数,然后把这个相机挂在场景相机,transform归零就ok了。这里我把camera的enable去掉了,我自己来调用render,因为这里可以自己控制更新频率。

这里渲染的时候分辨率降低一半,如果觉得还不够可以再降一些,这里为啥不一开始就用低分辨率呢,是因为,先以一个差不多的分辨率精度比较高,再降是取最大的mipmap等级,这样会保证正确性。

float4 GetMaxFeedback(float2 uv, int count)
{
	float4 col = float4(1, 1, 1, 1);
	for (int y = 0; y < count; y++)
	{
		for (int x = 0; x < count; x++)
		{
			float4 col1 = tex2D(_MainTex, uv + float2(_MainTex_TexelSize.x * x, _MainTex_TexelSize.y * y));
			col = lerp(col, col1, step(col1.b, col.b));
		}
	}
	return col;
}

这里之所以对分辨率要求这么高,是因为我们需要把贴图数据从rendertarget里取出到cpu来读取数据。

// 发起异步回读请求
m_ReadbackRequest = AsyncGPUReadback.Request(texture);

这是一个异步请求,我们可能有一个延迟,也就是说一般是这一帧使用的是上一帧的feedback

PageTable

页表是一个mipmap层级结构的表,比如说我们的格子是一个256×256的矩阵,那么mipmap为0的等级就是256×256的cell,mipmap为1的就是128×128的cell,依次类推。cell的上面除了存放mipmap等级,占据的rect,还有page的加载情况。

public class TableNodeCell
{
        public RectInt Rect { get; set; }

        public PagePayload Payload { get; set; }

        public int MipLevel { get; }

        public TableNodeCell(int x, int y, int width, int height,int mip)
        {
            Rect = new RectInt(x, y, width, height);
            MipLevel = mip;
            Payload = new PagePayload();
        }
}


这里要注意的是我们这的pagetable是个可变化的,针对移动是调整后面单独拎出来说。

上面feedback处理完得到一张feedback的贴图,上面各个像素表示需要显示的cell以及mipmap等级。我们可以通过这个信息,去触发加载TileTexture。

/// <summary>
/// 激活页表
/// </summary>
private TableNodeCell ActivatePage(int x, int y, int mip)
{
            if (mip > MaxMipLevel || mip < 0 || x < 0 || y < 0 || x >= TableSize || y >= TableSize)
                return null;
            // 找到当前页表
            var page = m_PageTable[mip].Get(x, y);
            if(page == null)
            {
                return null;
            }
            if(!page.Payload.IsReady)
            {
                LoadPage(x, y, page);

                //向上找到最近的父节点
                while(mip < MaxMipLevel &amp;&amp; !page.Payload.IsReady)
                {
                    mip++;
                    page = m_PageTable[mip].Get(x, y);
                }
            }

            if (page.Payload.IsReady)
            {
                // 激活对应的平铺贴图块
                m_TileTexture.SetActive(page.Payload.TileIndex);
                page.Payload.ActiveFrame = Time.frameCount;
                return page;
            }

            return null;
}
/// <summary>
/// 加载页表
/// </summary>
private void LoadPage(int x, int y, TableNodeCell node)
{
   if (node == null)
     return;

   // 正在加载中,不需要重复请求
   if(node.Payload.LoadRequest != null)
       return;

   // 新建加载请求
   node.Payload.LoadRequest = m_RenderTextureJob.Request(x, y, node.MipLevel);
}

TileTexture

tileTexture会设置tile的size,每个size的分辨率大小,以及padding像素

在这里我们有一个渲染队列,由于同一帧可能请求比较多,我们可以从mipmap等级高到底排序,然后控制每帧渲染数量来进行。

public void Update()
{
            if (m_PendingRequests.Count <= 0)
                return;

            // 优先处理mipmap等级高的请求
            m_PendingRequests.Sort((x, y) => { return x.MipLevel.CompareTo(y.MipLevel); });

            int count = m_Limit;
            while (count > 0 &amp;&amp; m_PendingRequests.Count > 0)
            {
                count--;
                // 将第一个请求从等待队列移到运行队列
                var req = m_PendingRequests[m_PendingRequests.Count - 1];
                m_PendingRequests.RemoveAt(m_PendingRequests.Count - 1);

                // 开始渲染
                StartRenderJob?.Invoke(req);
            }
}

在处理一个具体的tile时,由于我们需要烘焙diffuse,normal,Mask(暂时没处理),我们这里使用的MRT。

然后就转化到要把page上的某个rect,画到TileTexture的某个tile上了,这里还得考虑padding这些,涉及到非常繁琐的tileoffset的计算,这里花了我不少时间。除了渲染地形,以后如果有些需要贴到地形上的贴花也在这个过程中进行。

drawtexture的shader也就是常规的blend混合,这里要注意的是,对于地形超出四层的情况,第一个四层正常渲染,第二个四层以及后面的贴花可以采用Blend One One的形式

PixelOutput frag(v2f_drawTex i) : SV_Target
{
    float4 blend = tex2D(_Blend, i.uv * _BlendTile.xy + _BlendTile.zw);
    
#ifdef TERRAIN_SPLAT_ADDPASS
    clip(blend.x + blend.y + blend.z + blend.w <= 0.005h ? -1.0h : 1.0h);
#endif
    
    float2 transUv = i.uv * _TileOffset1.xy + _TileOffset1.zw;
    float4 Diffuse1 = tex2D(_Diffuse1, transUv);
    float4 Normal1 = tex2D(_Normal1, transUv);
    transUv = i.uv * _TileOffset2.xy + _TileOffset2.zw;
    float4 Diffuse2 = tex2D(_Diffuse2, transUv);
    float4 Normal2 = tex2D(_Normal2, transUv);
    transUv = i.uv * _TileOffset3.xy + _TileOffset3.zw;
    float4 Diffuse3 = tex2D(_Diffuse3, transUv);
    float4 Normal3 = tex2D(_Normal3, transUv);
    transUv = i.uv * _TileOffset4.xy + _TileOffset4.zw;
    float4 Diffuse4 = tex2D(_Diffuse4, transUv);
    float4 Normal4 = tex2D(_Normal4, transUv);

    PixelOutput o;
    o.col0 = blend.r * Diffuse1 + blend.g * Diffuse2 + blend.b * Diffuse3 + blend.a * Diffuse4;
    o.col1 = blend.r * Normal1 + blend.g * Normal2 + blend.b * Normal3 + blend.a * Normal4;
    return o;
}

(这里注意采样纹理一定要自己指定mipmap等级为0,不然显卡会降mipmap等级导致变模糊,这也是我后面发现的,文章和demo都没修改)

画完后就要通知pagetable我加载完毕,并且更新cell上的加载信息。

LookUp
在pagetable每帧更新中,会将TileTexture各个Tile激活下的数据写入lookup贴图,这个lookup的尺寸跟pagetable的cell格子数一样大小,rgb分别表示,cell坐标,mipmap等级。

这里要注意几个地方,第一,Active的page只需取存在feedback上的activepage,而不需取整个TileTexture上的。第二,active的page可能会有重合的地方,这里以mipmap等级低的覆盖等级高的。由于有这两点,我起初是直接在CPU创建一张Texture2D,往里填数据,后来测试发现这样会非常慢,特别是Texture2D的Apply。后来参考UE的代码采用GPUInstance直接画上去,这里由于需要使用mipmap等级低的覆盖高的,可以使用画家算法,按mipmap排序。ue还使用了莫顿码,我这里直接使用InstanceData传入进去整个数据

var mats = new Matrix4x4[drawList.Count];
var pageInfos = new Vector4[drawList.Count];
for(int i=0;i<drawList.Count;i++){
    float size = drawList[i].rect.width / TableSize;
    mats[i] = Matrix4x4.TRS(
                    new Vector3(drawList[i].rect.x / TableSize, drawList[i].rect.y / TableSize), 
                    Quaternion.identity,
                    new Vector3(size, size, size));
    pageInfos[i] = new Vector4(drawList[i].drawPos.x, drawList[i].drawPos.y, drawList[i].mip / 255f,0);
}
Graphics.SetRenderTarget(m_LookupTexture);
var tempCB = new CommandBuffer();
var block = new MaterialPropertyBlock();
block.SetVectorArray("_PageInfo", pageInfos);
block.SetMatrixArray("_ImageMVP", mats);
tempCB.DrawMeshInstanced(mQuad, 0, drawLookupMat,0, mats, mats.Length, block);
Graphics.ExecuteCommandBuffer(tempCB);

shader代码

UNITY_INSTANCING_BUFFER_START(InstanceProp)
UNITY_DEFINE_INSTANCED_PROP(float4, _PageInfo)
UNITY_DEFINE_INSTANCED_PROP(float4x4, _ImageMVP)
UNITY_INSTANCING_BUFFER_END(InstanceProp)
Varyings vert(Attributes IN)
{
	Varyings OUT ;
	UNITY_SETUP_INSTANCE_ID(IN);
	float4x4 mat = UNITY_MATRIX_M;
	mat = UNITY_ACCESS_INSTANCED_PROP(InstanceProp, _ImageMVP);
	float2 pos = saturate(mul(mat, IN.positionOS).xy);
	pos.y = 1 - pos.y;
	OUT.positionHCS = float4(2.0 * pos  - 1,0.5,1);
	OUT.color = UNITY_ACCESS_INSTANCED_PROP(InstanceProp, _PageInfo);
	return OUT;
}

half4 frag(Varyings IN) : SV_Target
{
	return IN.color;
}

这里Unity有几个坑逼的地方,首先unity调用drawMeshInstanced传入的那个矩阵他会修改里边的数据,导致我不能直接使用那个数据,因为我这里不是标准的MVP矩阵,他可能帮我转换了,我这里重新弄了一个矩阵instanceData。还有一点就是如果你在shader里不使用UNITY_MATRIX_M这个变量,unity就认为你这个不是drawInstance。所以我在shader里加了一句无意义的代码float4x4 mat = UNITY_MATRIX_M;

DrawVT

所有数据准备完毕后,现在就比较简单了。直接使用RVT贴图里的diffuse和Normal,然后参与光照计算就OK了。

half4 GetRVTColor(Varyings IN)
{
    float2 uv = (IN.positionWS.xz - _VTRealRect.xy) / _VTRealRect.zw;
    float2 uvInt = uv - frac(uv * _VTPageParam.x) * _VTPageParam.y;
	float4 page = tex2D(_VTLookupTex, uvInt) * 255;
#ifdef _SHOWRVTMIPMAP
    return float4(clamp(1 - page.b * 0.1 , 0, 1), 0, 0, 1);
#endif
	float2 inPageOffset = frac(uv * exp2(_VTPageParam.z - page.b));
    uv = (page.rg * (_VTTileParam.y + _VTTileParam.x * 2) + inPageOffset * _VTTileParam.y + _VTTileParam.x) / _VTTileParam.zw;
    half3 albedo = tex2D(_VTDiffuse, uv);
    half3 normalTS = UnpackNormalScale(tex2D(_VTNormal, uv), 1);
    InputData inputData;
    InitializeInputData(IN, normalTS, inputData);
    half metallic = 0;
    half smoothness = 0.1;
    half occlusion = 1;
    half alpha = 1;
    half4 color = UniversalFragmentPBR(inputData, albedo, metallic, /* specular */ half3(0.0h, 0.0h, 0.0h), smoothness, occlusion, /* emission */ half3(0, 0, 0), alpha);
    SplatmapFinalColor(color, inputData.fogCoord);

    return half4(color.rgb, 1.0h);
}

动态更新
以上基本就能把地形通过rvt画出来了,但是这样有个基本限制,那就是我们移动的时候,如果是超大世界,那么这个pagetable对应的位置就有限。ue的解决办法是每个地形一套rvt,这样会造成,在地形边界可能出现四套rvt,这基本不能接受。天刀采用的是动态更新pagetable,也就是说pagetable对应的cell表示的范围会动态变化。

在changerect的时候,我们需要最大限度地复用TileTexture,不然需要重绘所有的TileTexture,这样会造成比较大的卡顿。这里对于changrect的rect的规范就比较有讲究了,我们需要fixed这个rect,让他尽可能地多复用pagetable。我们这是通过设置更新距离为整个可视距离的四分之一或者八分之一这种,然后通过这个四分之一去fixed可视范围center

Unity大世界LightMap处理


在Unity中,烘焙LightMap采用的是一个场景烘焙一组LightMap。而对于大世界场景来说,没办法把世界上所有的物体在同一场景下烘焙。Unity提供的解决办法是通过SubScene来解决,就是分场景烘焙,然后再通过加载卸载Scene的方式来实现。但有时候有这样的需求,同一组室内场景可能在多个地方存在,美术希望烘焙好一组物体,能复制到各个地方,并且能很好地预览,这样使用SubScene来说就比较麻烦了。

先说一下 unity的LightMap机制,烘焙分为动态物体和静态物体,动态物体走的是GI,通过环境光,LightProb等这些算出三维光照数据,然后计算动态物体的球谐光照。对于静态物体来说就会烘焙成 lightmap,一般有几组三张贴图(color,dir以及shadow)。静态物体的MeshRender上会有个lightmapIndex存放采用第几组lightmap,还有个lightmapScaleOffset存放uv偏移,通过这两个数据就能显示正确。

知道LightMap的原理后就比较简单了,我们只需要存好我们需要使用的数据,然后设置对应的位置就能正确显示了。

首先,我们定义好我们的数据结构,我们期望在一个prefab上挂一个我们的脚本,然后加载这个prefab上所有的MeshRender。我们就需要一个这样的ScriptObject。

public class CustomLightMapDataMap : ScriptableObject
{
    public MeshLightmapData[] LightMapDatas = null;
}
[Serializable]
public struct CustomLightmapData
{
    /// <summary>
    /// The color for lightmap.
    /// </summary>
    public Texture2D LightmapColor;

    /// <summary>
    /// The dir for lightmap.
    /// </summary>
    public Texture2D LightmapDir;

    /// <summary>
    /// The shadowmask for lightmap.
    /// </summary>
    public Texture2D ShadowMask;

    /// <summary>
    /// Initializes a new instance of the <see cref="CustomLightmapData"/> struct.
    /// </summary>
    /// <param name="data">lightmapdata.</param>
    public CustomLightmapData(LightmapData data)
    {
        this.LightmapColor = data.lightmapColor;
        this.LightmapDir = data.lightmapDir;
        this.ShadowMask = data.shadowMask;
    }

    public bool IsA(LightmapData data)
    {
        return this.LightmapColor == data.lightmapColor &amp;&amp;
        this.LightmapDir == data.lightmapDir &amp;&amp;
        this.ShadowMask == data.shadowMask;
    }

    public LightmapData GetLightmapData()
    {
        LightmapData data = new LightmapData();
        data.lightmapColor = this.LightmapColor;
        data.lightmapDir = this.LightmapDir;
        data.shadowMask = this.ShadowMask;
        return data;
    }
}

[Serializable]
public struct MeshLightmapData
{
    public Vector4 LightmapScaleOffset;

    public CustomLightmapData LightmapData;
}

然后再在编辑器上弄一个菜单,选中物体就能自动干这件事情。

[MenuItem("Window/LightMapGenerate")]
    private static void Generated()
    {
        string outputPath = "Assets/LightMapPrefab";
        var lightmapPath = GetLightMapPath();
        if (!string.IsNullOrEmpty(lightmapPath))
        {
            outputPath = Path.GetDirectoryName(lightmapPath);
        }

        GameObject obj = Selection.activeGameObject;
        if (obj == null)
        {
            return;
        }

        var dataMap = (CustomLightMapDataMap)ScriptableObject.CreateInstance(typeof(CustomLightMapDataMap));
        var renders = obj.GetComponentsInChildren<MeshRenderer>();
        List<MeshLightmapData> datas = new List<MeshLightmapData>();
        var lightmaps = LightmapSettings.lightmaps;
        foreach (var render in renders)
        {
            if (render.lightmapIndex < 0 || render.lightmapIndex >= lightmaps.Length)
            {
                Debug.LogError("lightmap error:" + render.gameObject.name);
                return;
            }
            var data = new MeshLightmapData()
            {
                LightmapScaleOffset = render.lightmapScaleOffset,
                LightmapData = new CustomLightmapData(lightmaps[render.lightmapIndex]),
            };
            datas.Add(data);
        }

        dataMap.LightMapDatas = datas.ToArray();
        var loader = obj.GetComponent<LightMapDataLoader>();
        if (loader == null)
        {
            loader = obj.AddComponent<LightMapDataLoader>();
        }

        outputPath = Path.Combine(outputPath, obj.name + ".asset");
        AssetDatabase.CreateAsset(dataMap, outputPath);
        AssetDatabase.SaveAssets();
        loader.Asset = AssetDatabase.LoadAssetAtPath<CustomLightMapDataMap>(outputPath);
    }

    private static string GetLightMapPath()
    {
        var lightmaps = LightmapSettings.lightmaps;
        if (lightmaps.Length == 0)
        {
            return string.Empty;
        }

        return AssetDatabase.GetAssetPath(lightmaps[0].lightmapColor);
    }

数据保存好了,我们只需要加载就好了。加载除了要加载MeshRender上的数据,还要设置好场景的LightMap。这里还有个特别重要的问题就是卸载,在物体销毁时,我们要处理场景的lightmap,这里需要通过一个计数器去干这件事情,当引用计数为0了,我们就去清理lightmap贴图数据。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;

[ExecuteInEditMode]
public class LightMapDataLoader : MonoBehaviour
{
    private static Dictionary<CustomLightmapData, int> lightmapDataRefenceCount = new Dictionary<CustomLightmapData, int>();

    [SerializeField]
    private CustomLightMapDataMap asset;

    private HashSet<CustomLightmapData> lightmapDatas = new HashSet<CustomLightmapData>();

    public CustomLightMapDataMap Asset
    {
        get { return this.asset; }
        set { this.asset = value; }
    }

    public static void Clear()
    {
        lightmapDataRefenceCount.Clear();
    }

    // Start is called before the first frame update
    private void Awake()
    {
        if (this.asset != null)
        {
            var lightmaps = LightmapSettings.lightmaps;
            var renders = this.GetComponentsInChildren<MeshRenderer>();
            var datas = this.asset.LightMapDatas;
            if (datas.Length != renders.Length)
            {
                return;
            }

            List<LightmapData> lightmapList = new List<LightmapData>(lightmaps);
            for (int i = 0; i < datas.Length; i++)
            {
                var lightMapIndex = -1;
                var nullIndex = -1;
                LightmapData currentData = null;
                for (int j = lightmapList.Count - 1; j >= 0; j--)
                {
                    var lightmap = lightmapList[j];
                    if (datas[i].LightmapData.IsA(lightmap))
                    {
                        lightMapIndex = j;
                        currentData = lightmap;
                    }

                    if (lightmap.lightmapColor == null &amp;&amp;
                        lightmap.lightmapDir == null &amp;&amp;
                        lightmap.shadowMask == null)
                    {
                        nullIndex = j;
                    }
                }

                if (lightMapIndex == -1)
                {
                    currentData = datas[i].LightmapData.GetLightmapData();
                    if (nullIndex == -1)
                    {
                        lightmapList.Add(currentData);
                        lightMapIndex = lightmapList.Count - 1;
                    }
                    else
                    {
                        lightmapList[nullIndex] = currentData;
                        lightMapIndex = nullIndex;
                    }
                }

                this.lightmapDatas.Add(datas[i].LightmapData);
                renders[i].lightmapIndex = lightMapIndex;
                renders[i].lightmapScaleOffset = datas[i].LightmapScaleOffset;
            }

            foreach (var data in this.lightmapDatas)
            {
                if (!lightmapDataRefenceCount.TryGetValue(data, out var count))
                {
                    count = 0;
                }
                else
                {
                    lightmapDataRefenceCount.Remove(data);
                }

                count++;
                lightmapDataRefenceCount.Add(data, count);
            }

            LightmapSettings.lightmaps = lightmapList.ToArray();
        }
    }

    private void OnDestroy()
    {
        foreach (var data in this.lightmapDatas)
        {
            if (lightmapDataRefenceCount.TryGetValue(data, out var count))
            {
                count--;
                lightmapDataRefenceCount.Remove(data);
                if (count == 0)
                {
                    var lightmaps = LightmapSettings.lightmaps;
                    for (int i = 0; i < lightmaps.Length; i++)
                    {
                        if (data.IsA(lightmaps[i]))
                        {
                            lightmaps[i].lightmapColor = null;
                            lightmaps[i].lightmapDir = null;
                            lightmaps[i].shadowMask = null;
                        }
                    }

                    LightmapSettings.lightmaps = lightmaps;
                }
                else
                {
                    lightmapDataRefenceCount.Add(data, count);
                }
            }
        }
    }
}

做好这些事情之后,我们就可以在场景中烘焙一组物体,然后选中Root,点击Window/LightMapGenerate,会帮你组织好数据,挂好脚本。你可以把这个物体复制到任何地方都是显示正确,也可以保存成prefab通过程序加载和销毁。

分区域烘焙时交界处的阴影处理

分区域烘焙 LightMap 时,交界处最容易出现的问题是阴影断开、漏光、间接光颜色不连续。这个问题的核心不在于运行时怎么加载,而在于烘焙时如果只把当前区域当成一个孤岛,隔壁区域的遮挡物、灯光和 GI bounce 都没有参与计算,那么当前区域边缘自然就拿不到正确的阴影和间接光。

我的处理原则是:区域可以分开保存,但烘焙时不能分开看。也就是说,每个区域烘焙时都要带上周围区域作为上下文,但最终只导出当前区域自己的 LightMap 绑定数据。

Bake Chunk_A:
Target  : Chunk_A
Context : Chunk_A 周围一圈或多圈邻区
Compute : Target + Context 一起参与阴影、遮挡和 GI
Export  : 只保存 Chunk_A 的 Renderer 映射、LightMap Index、Scale Offset 和被引用的贴图
Runtime : 只加载 Chunk_A 自己的 LightMap 数据

具体可以拆成几个步骤:

  1. 烘焙时加载邻区上下文。例如烘 A 区时,不只加载 A 区,还加载周围 8 个 Chunk,邻区几何和灯光只作为 shadow caster / GI contributor 参与计算。导出阶段只采集 A 区 Renderer 的 lightmapIndex、lightmapScaleOffset 和它实际引用到的 LightMap 贴图。
  2. 给区域边界留 overlap 或 guard band。切块不要刚好卡在可见边界,可以让每个区域多带一圈缓冲范围。运行时只使用中间有效区,缓冲区用于保证边缘阴影和 GI 过渡连续。
  3. 跨边界的大物体不要硬切。如果建筑、山体、墙体或大型遮挡物横跨多个区域,最好让它归属到一个稳定区域,或者做一个专门的边界代理物。最怕的是投影物在 B 区,但烘 A 区时没加载它,A 区地面就永远缺那块阴影。
  4. Context 可以用低成本代理。邻区不一定要用完整美术模型,可以用简化 Mesh、低 lightmap scale 或只保留关键遮挡体。目标是让阴影和 GI 正确,不是把邻区也高质量烘一遍。
  5. 控制 UV padding 和 dilation。区域边缘和高对比阴影处要避免 UV chart 太贴,LightMap padding / dilation 不够时,mipmap 后会串色,边缘就会出现脏边或断层。
  6. 大尺度间接光用 Probe 辅助。LightMap 负责静态细节,跨区域的大范围环境色、角色受光和动态物体受光,建议配合 Light Probe / Probe Volume / SH Grid / Reflection Probe 处理。这样区域流式加载时,不会因为某个 Chunk 的 LightMap 切换导致整体光照突然变色。

在当前这套 Prefab LightMap 数据保存方案里,可以把“目标区”和“上下文区”分开记录:目标区的 Renderer 进入导出列表,上下文区只在 BakeScene 中临时存在。烘焙结束后,导出工具只保存目标区相关的 Renderer 绑定关系;如果某张 LightMap 贴图被目标区 Renderer 引用,就一起保存,否则可以丢弃。这样运行时还是按区域加载 LightMap,但烘焙结果会继承邻区阴影和 GI 信息。

简单说就是:单区输出,多区参与。这是分块 LightMap 里处理接缝阴影最稳的方式。

Custom Shading Model In UE5/自定义光照模型

最近在卷UE5的自定义光照模型,所以就写一个笔记,记录一下/Recently, I’ve been experimenting with custom shading models in Unreal Engine 5 (UE5), so I decided to write some notes to keep track of my progress.

分三个模块,1.拉取源代码,2.C++部分添加custom shading model,3.shader 部分/Divide it into three modules: 1. Fetching the source code, 2. Adding the custom shading model in C++, 3. Working on the shader part.

一. 拉取UE源代码/Fetching the source code

主要步骤还是根据UE官方档案来操作 从源代码构建虚幻引擎 | 虚幻引擎文档 (unrealengine.com)/The main steps to build Unreal Engine from source code should be followed based on the official documentation provided by Unreal Engine.

二.C++部分添加Custom Shading Model/To add a custom shading model in C++

首先我们得先让UE知道我们有一套新的Shading Model,能够在光照模型的下拉菜单中找到我们的custom shading model,所以我们先修改EngineTypes.h跟MaterialShader.cpp这两个文件,本质是添加一个新的枚举/

To make Unreal Engine aware of your custom shading model and have it appear in the dropdown menu of the lighting model, you need to modify the EngineTypes.h and MaterialShader.cpp files. The essence of this modification is to add a new enumeration.

EngineTypes.h的头文件中,在EMaterialShadingModel中添加我们CustonShadingModel
在MaterialShader.cpp文件中,在GetShadingModelString(EMaterialShadingModel ShadingModel)中添加

给shader添加一个宏,让shader走新的分支

HLSLMaterialTranslator.cpp

为了之后的Shader操作,我们需要给我们的shader开放两个CustomData端口,分别在Material.cpp跟MaterialAttributeDefinitionMap.cpp文件

Material.cpp中在两个Customdata中增加我们的ShadingModel的名称
在MaterialAttributeDefinitionMap.cpp增加两个定义

接着我们让新的ShadingModel写入GBuffer,这里我们需要修改两个文件ShaderMaterial.h跟ShaderMaterialDerivedHelpers.cpp

在ShaderMaterial.h中添加我们的光照模型
ShaderMaterialDerivedHelpers.cpp中添加
Dst.WRITES_CUSTOMDATA_TO_GBUFFER = (Dst.USES_GBUFFER &amp;&amp; (Mat.MATERIAL_SHADINGMODEL_SUBSURFACE || Mat.MATERIAL_SHADINGMODEL_PREINTEGRATED_SKIN || Mat.MATERIAL_SHADINGMODEL_SUBSURFACE_PROFILE || Mat.MATERIAL_SHADINGMODEL_CLEAR_COAT || Mat.MATERIAL_SHADINGMODEL_TWOSIDED_FOLIAGE || Mat.MATERIAL_SHADINGMODEL_HAIR || Mat.MATERIAL_SHADINGMODEL_CLOTH || Mat.MATERIAL_SHADINGMODEL_EYE || Mat.MATERIAL_SHADINGMODEL_TOON));

接着我们在ShaderGenerationUtil.cpp中添加生成

至此我们的C++部分修改已经完成,总的来说没什么难度,基本就是照葫芦画瓢,我们编译一下看看引擎

At this point, we have completed the modifications in the C++ part. Overall, it wasn’t difficult; it was mostly a matter of following a similar pattern. Let’s compile the engine now to see if our changes are successful.

看到这几个就相当于添加成功了

接着我们开始修改shader部分

Let’s continue with the modification of the shader part.

首先在ShadingCommon.ush中添加custom Shading Model的宏定义,顺序要和之前C++中的保持一致。

我们一开始定义的ShadingModel在NUM之前,所以在这的顺序不变,顺便NUM的ID也从13变为14

接着我们定义一下debug view下的ShadingModel的颜色

依然是在同一个文件中

然后我们在Definitions.usf中定义宏的默认值

现在我们在BasePassCommon.ush输出GBuffer

在最后加上MATERIAL_SHADINGMODEL_TOON

这步之后我们可以在Debug状态下看见我们的ShadingModel,现在我们输出CustomData分别修改DeferredShadingCommon.ush文件与ShadingModelsMaterial.ush

在DeferredShadingCommon.ush文件中增加我们的新建的ShadingModel的ID

下面就是正菜,我们先新建一个ush专门放新加的函数。

这个ush文件放在Engine/Shaders/Private这个路径下

我们下面来到Shadingmodels.ush这个文件先include我们刚才创建的头文件

接着添加着色算法

//Toon Shading model
FDirectLighting ToonBxDF(FGBufferData GBuffer, half3 N, half3 V, half3 L, float Falloff, float NoL, FAreaLight AreaLight, FShadowTerms Shadow)
{
	#if GBUFFER_HAS_TANGENT
		half3 X = GBuffer.WorldTangent;
		half3 Y = normalize(cross(N,X));
	#else	
		half3 X = 0;
		half3 Y = 0;
	#endif
	
	BxDFContext Context;
	Init(Context, N, X, Y, V, L);
	SphereMaxNoH(Context, AreaLight.SphereSinAlpha, true);
	Context.NoV = saturate(abs(Context.NoV) + 1e-5);
	
	float SpecularOffset = 0.5;
	float SpecularRange = GBuffer.CustomData.x;
	
	float3 ShadowColor = 0;
	ShadowColor = GBuffer.DiffuseColor * ShadowColor;
	float offset = GBuffer.CustomData.y;
	float SoftScatterStrength = 0;
	
	offset = offset * 2 - 1;
	half3 H = normalize(V + L);
	float NoH = saturate(dot(N, H));
	NoL = (dot(N, L) + 1) / 2; // overwrite NoL to get more range out of it
	half NoLOffset = saturate(NoL + offset);
	
	FDirectLighting Lighting;
	Lighting.Diffuse = AreaLight.FalloffColor * (smoothstep(0, 1, NoLOffset) * Falloff) * Diffuse_Lambert(GBuffer.DiffuseColor) * 2.2;
	
	float InScatter = pow(saturate(dot(L, -V)), 12) * lerp(3, .1f, 1);
	float NormalContribution = saturate(dot(N, H));
	float BackScatter = GBuffer.GBufferAO * NormalContribution / (PI * 2);
	
	Lighting.Specular = ToonStep(SpecularRange, (saturate(D_GGX(SpecularOffset, NoH)))) * (AreaLight.FalloffColor * GBuffer.SpecularColor * Falloff * 8);

	float3 TransmissionSoft = AreaLight.FalloffColor * (Falloff * lerp(BackScatter, 1, InScatter)) * ShadowColor * SoftScatterStrength;
	float3 ShadowLightener = (saturate(smoothstep(0, 1, saturate(1 - NoLOffset))) * ShadowColor * 0.1);
	
	Lighting.Transmission = (ShadowLightener + TransmissionSoft) * Falloff;
	
	return Lighting;
	
}

			float3 Attenuation = 1;
			BRANCH

			if (GBuffer.ShadingModelID == SHADINGMODELID_TOON)
			{
				float offset = GBuffer.CustomData.y;
				float TerminatorRange = saturate(GBuffer.Roughness - 0.5);
				
				offset = offset * 2 - 1;
				
				BRANCH

				if (offset >= 1)
				{
					Attenuation = 1;
				}
				else
				{
					float NoL = (dot(N, L) + 1) / 2;
					float NoLOffset = saturate(NoL + offset);
					float LightAttenuationOffset = saturate(Shadow.SurfaceShadow + offset);
					float ToonSurfaceShadow = smoothstep(0.5 - TerminatorRange, 0.5 + TerminatorRange, LightAttenuationOffset);

					Attenuation = smoothstep(0.5 - TerminatorRange, 0.5 + TerminatorRange, NoLOffset) * ToonSurfaceShadow;
				}
			}
			//Toon Shading Model
在DefeeredLightingCommon.ush中添加以上代码以及修改以下代码

最后我们重新编译一下看一下效果

动态平面阴影

Shader "fake_shadow"
{
    Properties
    {
        //材质属性面板
        _MainTex ("主贴图",2D) = "white"{}

        _GroundY ("地面Y高度 (外部传入)",float) = 0
        _Shadow_Color("影子颜色",Color) = (1,1,1,1)
        _Shadow_Length("影子长度",float) = 0
        _Shadow_Rotated("影子旋转角度",float) = 0
    }

    SubShader
    {
        Tags
        {
            "Queue" = "Transparent+5" /*"RenderType"="Transparence"  "RenderPipeline" = "UniversalPipeline" *///注意这里很重要,因为影子是要绘制在地面上,所以地面必须应该先绘制,否则blend混合的时候就是和背后的skybox进行混合了
        }

        pass
        {
            Stencil{

                Ref 1
                //Comp取值依次为  0:Disabled  1:Never  2:Less  3:Equal  4:LessEqual  5:Greater  6:NotEqual  7:GreaterEqual  8:Always
                Comp NotEqual //或者改成NotEqual
                //Pass取值依次为  0:Keep  1:Zero  2:Replace  3:IncrementSaturate  4:DecrementSaturate  5:Invert  6:IncrementWrap  7:DecrementWrap
                Pass Replace
            }

            Blend SrcAlpha oneMinusSrcAlpha   

            //因为和地面重叠所以做个偏移
            //也可以不做偏移,将传入的地面高度抬高一点即可
            Offset -2,-2

            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
                //这里worldPos一定是float4,因为vert()中实际是手动两次空间变换如果是float3会导致w分量丢失,透视除法会出错
                //如果不参与变换,只是传到frag()中使用的话,比如进行Blinn-Phong光照计算V向量那么float3就够了
                float4 worldPos : TEXCOORD0;
                //做阴影插值和Clip地面以下阴影用
                float cacheWorldY : TEXCOORD1;
                float worldPosY : TEXCOORD2;
};
 //           CBUFFER_START
CBUFFER_START(UnityPerMaterial)
            half _GroundY;
            half4 _Shadow_Color;   
            half _Shadow_Length;     
            half _Shadow_Rotated;
CBUFFER_END
//            CBUFFER_END
            
            v2f vert(appdata v)
            {
                v2f o = (v2f)0;

                //获取世界空间的位置
                o.worldPos = mul(unity_ObjectToWorld,v.vertex);
                //缓存世界空间下的y分量,后续两点作用
                //第一点 : 做插值用做计算xz的偏移量的多少
                //第二点 : 防止在地面以下
                o.cacheWorldY = o.worldPos.y;
    
                o.worldPosY = UnityObjectToWorldNormal(v.vertex).y;

                //设置世界空间下y的值全部都设置为传入的地面高度值
                o.worldPos.y = _GroundY;

                //根据世界空间下模型y值减去传入的地面高度值_GroundY
                //以这个值为传入 lerp(0,_Shadow_Length) 进行线性插值
                //最后获取到模型y值由低到高的插值lerpVal
                //这个max()函数 假设腿部在地面以下则裁切掉腿部阴影,后续使用clip后无需Max
                //half lerpVal = lerp(0,_Shadow_Length,max(0,o.cacheWorldY-_GroundY));
                half lerpVal = lerp(0,_Shadow_Length,o.cacheWorldY-_GroundY);
                

                //常量PI
                //const float PI = 3.14159265;
                //角度转换成弧度
                half radian = _Shadow_Rotated / 180.0 * UNITY_PI;

                //旋转矩阵,对(0,1)向量进行旋转,计算旋转后的向量,该向量就是阴影方向
                //2D旋转矩阵如下
                // [x]        [ cosθ , -sinθ ]
                // [ ]  乘以  
                // [y]        [ sinθ , cosθ  ]
                // x' = xcosθ - ysinθ
                // y' = xsinθ + ycosθ
                half2 ratatedAngle = half2((0*cos(radian)-1*sin(radian)),(0*sin(radian)+1*cos(radian)));
                
                //用以y轴高度为参考计算的插值 lerpVal 去 乘以一个旋转后的方向向量,作为阴影的方向
                //最终得到偏移后的阴影位置
                o.worldPos.xz += lerpVal * ratatedAngle;
                
                //变换到裁剪空间
                o.pos = mul(UNITY_MATRIX_VP,o.worldPos);

                return o;
            }

            fixed4 frag(v2f i) : SV_TARGET
            {
                //剔除低于地面部分的片段
                clip(i.cacheWorldY - _GroundY);
                //用作阴影的Pass直接输出颜色即可
                return _Shadow_Color;
            }

            ENDCG
        }
    }
}
using UnityEngine;

public class SetShadowRotationAndHeight : MonoBehaviour
{

    public int materialIndex = 2; // 设置材质索引

    [SerializeField]
    private Transform rootTransform = null;          //创建模型时设置

    private Renderer rend = null;
    private bool isFindRend = false;
    private float extend = 30f;


    private Transform lightTrans=null;

    void Awake()
    {
        if (GetComponent<MeshFilter>() != null)
        {
            Mesh mesh = GetComponent<MeshFilter>().mesh;
            Bounds bd = mesh.bounds;
            bd.Expand(extend);
            mesh.bounds = bd;
        }
        else if(GetComponent<SkinnedMeshRenderer>() != null)
        {
            //SkinnedMeshRenderer skinnedMesh = GetComponent<SkinnedMeshRenderer>();
            //Bounds bd = skinnedMesh.bounds;
            //bd.Expand(extend);
            //skinnedMesh.bounds = bd;
        }
    }

    public void SetRootTransFrom(Transform root)
    {
        rootTransform = root;

        //GameObject mainLightObj = GameObject.Find("MainLight_Day");
        //if(mainLightObj==null)
        //{
        //    mainLightObj = GameObject.Find("FillLight_Day");
        //}
        //if (mainLightObj != null)
        //    lightTrans = mainLightObj.transform;
    }

    public Renderer GetTransformRenderer()
    {
        if (rend != null)
            return rend;
        if (isFindRend)
            return null;
        rend= GetComponent<Renderer>();
        isFindRend = true;
        return rend;
    }

    bool isDebug = false;

    void Update()
    {
        //if (lightTrans == null)
        //{
        //    if(!isDebug)
        //    {
        //        Debug.Log("找不到光源");
        //        isDebug = true;
        //    }
        //    return;
        //}
            

        if (rootTransform == null)
        {
            rootTransform = transform;
        }
        Renderer tempRend = GetTransformRenderer();

        if (tempRend == null)
            return;

        // 确保指定的材质索引有效
        if (materialIndex < 0 || materialIndex >= tempRend.materials.Length)
        {
            Debug.LogError(transform.name + "Invalid material index!");
            return;
        }

        Material mat = tempRend.materials[materialIndex]; // 获取指定索引的材质

        // 获取父节点的 Y 坐标
        float parentY = rootTransform.position.y + 0.2f;

        // 将父节点的 Y 坐标传递给指定索引的材质的 _GroundY 属性
        mat.SetFloat("_GroundY", parentY);

        // 获取主光源的方向

        if (RenderSettings.sun == null)
            return;

        Vector3 lightDirection = -RenderSettings.sun.transform.forward;
        //Vector3 lightDirection = -lightTrans.forward;

        // 计算主光源的旋转角度的 Y 值(使用 Atan2 函数)
        float shadowRotationY = Mathf.Atan2(lightDirection.x, lightDirection.z) * Mathf.Rad2Deg;

        // 将主光源的旋转角度的 Y 值传递给指定索引的材质的 _Shadow_Rotated 属性
        mat.SetFloat("_Shadow_Rotated", shadowRotationY);
    }
}

Material Blend Tools

写了个基于Height Map的Texture Mix的工具,应该还有提升的空间先看看效果

主要思路是通过splat_control这张贴图的四个通道控制_Splat0~_Splat3这四张贴图的混合,如果splat_control对应通道的值为1,那么这个通道对应的贴图就完全显示,为0则完全不显示,通过修改splat_control贴图就可以实现想要的混合效果了;

核心代码如下

fixed4 splat_control = tex2D (_Control, IN.uv_Control).rgba;	
	fixed3 lay1 = tex2D (_Splat0, IN.uv_Splat0);
	fixed3 lay2 = tex2D (_Splat1, IN.uv_Splat1);
	fixed3 lay3 = tex2D (_Splat2, IN.uv_Splat2);
	fixed3 lay4 = tex2D (_Splat3, IN.uv_Splat3);
	_Alpha = 0.0;
	Albedo.rgb = (lay1 * splat_control.r + lay2 * splat_control.g + lay3 * splat_control.b+ lay4 * splat_control.a);

混合处

float3 blend(float3 lay1, float3 lay2, float4 splat_control)
{
        float b1 = lay1.a * splat_control.r;
        float b2 = lay2.a * splat_control.g;
        float ma = max(b1,b2);
        b1 = max(b1 - (ma – 0.3), 0) * splat_control.r;
        b2 = max(b2 - (ma – 0.3), 0) * splat_control.g;

        return (lay1.rgb * b1 + lay2.rgb * b2)/(b1 + b2);
}

下面是工具

using UnityEngine;
using System.Collections;

[ExecuteInEditMode]
[RequireComponent(typeof(MeshCollider))]
public class MeshPainter : MonoBehaviour {

    void Start () {
	
	}
	

	void Update () {
	
	}
}

using UnityEngine;
using UnityEditor;
using System.IO;
using System.Collections;

[CustomEditor(typeof(MeshPainter))]
[CanEditMultipleObjects]
public class MeshPainterStyle : Editor
{
    string contolTexName = "";

    bool isPaint;

    float brushSize = 16f;
    float brushStronger = 0.5f;

    Texture[] brushTex;
    Texture[] texLayer;

    int selBrush = 0;
    int selTex = 0;


    int brushSizeInPourcent;
    Texture2D MaskTex;
    void OnSceneGUI()
    {
        if (isPaint)
        {
            Painter();
        }

    }
    public override void OnInspectorGUI()
        
    {
        if (Cheak())
        {
            GUIStyle boolBtnOn = new GUIStyle(GUI.skin.GetStyle("Button"));//得到Button样式
            GUILayout.BeginHorizontal();
                GUILayout.FlexibleSpace();
                isPaint = GUILayout.Toggle(isPaint, EditorGUIUtility.IconContent("ClothInspector.PaintValue"), boolBtnOn, GUILayout.Width(35), GUILayout.Height(25));//编辑模式开关
            GUILayout.FlexibleSpace();
            GUILayout.EndHorizontal();
            brushSize = (int)EditorGUILayout.Slider("Brush Size", brushSize, 1, 36);//笔刷大小
            brushStronger = EditorGUILayout.Slider("Brush Stronger", brushStronger, 0, 1f);//笔刷强度

            IniBrush();
            layerTex();
            GUILayout.BeginHorizontal();
                GUILayout.FlexibleSpace();
                    GUILayout.BeginHorizontal("box", GUILayout.Width(340));
                    selTex = GUILayout.SelectionGrid(selTex, texLayer, 4, "gridlist", GUILayout.Width(340), GUILayout.Height(86));
                    GUILayout.EndHorizontal();
                GUILayout.FlexibleSpace();
            GUILayout.EndHorizontal();

            GUILayout.BeginHorizontal();
                GUILayout.FlexibleSpace();
                    GUILayout.BeginHorizontal("box", GUILayout.Width(318));
                    selBrush = GUILayout.SelectionGrid(selBrush, brushTex, 9, "gridlist", GUILayout.Width(340), GUILayout.Height(70));
                    GUILayout.EndHorizontal();
                GUILayout.FlexibleSpace();
            GUILayout.EndHorizontal();
        }
        
    }

    //获取材质球中的贴图
    void layerTex()
    {
        Transform Select = Selection.activeTransform;
        texLayer = new Texture[4];
        texLayer[0] = AssetPreview.GetAssetPreview(Select.gameObject.GetComponent<MeshRenderer>().sharedMaterial.GetTexture("_Splat0")) as Texture;
        texLayer[1] = AssetPreview.GetAssetPreview(Select.gameObject.GetComponent<MeshRenderer>().sharedMaterial.GetTexture("_Splat1")) as Texture;
        texLayer[2] = AssetPreview.GetAssetPreview(Select.gameObject.GetComponent<MeshRenderer>().sharedMaterial.GetTexture("_Splat2")) as Texture;
        texLayer[3] = AssetPreview.GetAssetPreview(Select.gameObject.GetComponent<MeshRenderer>().sharedMaterial.GetTexture("_Splat3")) as Texture;
    }

    //获取笔刷  
    void IniBrush()
    {
        string MeshPaintEditorFolder = "Assets/MeshPaint/Editor/";
        ArrayList BrushList = new ArrayList();
        Texture BrushesTL;
        int BrushNum = 0;
        do
        {
            BrushesTL = (Texture)AssetDatabase.LoadAssetAtPath(MeshPaintEditorFolder + "Brushes/Brush" + BrushNum + ".png", typeof(Texture));

            if (BrushesTL)
            {
                BrushList.Add(BrushesTL);
            }
            BrushNum++;
        } while (BrushesTL);
        brushTex = BrushList.ToArray(typeof(Texture)) as Texture[];
    }

    //检查
    bool Cheak()
    {
        bool Cheak = false;
        Transform Select = Selection.activeTransform;
        Texture ControlTex = Select.gameObject.GetComponent<MeshRenderer>().sharedMaterial.GetTexture("_Control");
        if(Select.gameObject.GetComponent<MeshRenderer>().sharedMaterial.shader == Shader.Find("4Tex_Blend_Normal") || Select.gameObject.GetComponent<MeshRenderer>().sharedMaterial.shader == Shader.Find("zcxshaderlibrary/texBlendWithBump"))
        {
            if(ControlTex == null)
            {
                EditorGUILayout.HelpBox("当前模型材质球中未找到Control贴图,绘制功能不可用!", MessageType.Error);
                if (GUILayout.Button("创建Control贴图"))
                {
                    creatContolTex();
                    //Select.gameObject.GetComponent<MeshRenderer>().sharedMaterial.SetTexture("_Control", creatContolTex());
                }
            }
            else
            {
                Cheak = true;
            }
        }
        else 
        {
            EditorGUILayout.HelpBox("当前模型shader错误!请更换!", MessageType.Error);
        }
        return Cheak;
    }

    //创建Contol贴图
    void creatContolTex()
    {

        //创建一个新的Contol贴图
        string ContolTexFolder = "Assets/MeshPaint/Controler/";
        Texture2D newMaskTex = new Texture2D(512, 512, TextureFormat.ARGB32, true);
        Color[] colorBase = new Color[512 * 512];
        for(int t = 0; t< colorBase.Length; t++)
        {
            colorBase[t] = new Color(1, 0, 0, 0);
        }
        newMaskTex.SetPixels(colorBase);

        //判断是否重名
        bool exporNameSuccess = true;
        for(int num = 1; exporNameSuccess; num++)
        {
            string Next = Selection.activeTransform.name +"_"+ num;
            if (!File.Exists(ContolTexFolder + Selection.activeTransform.name + ".png"))
            {
                contolTexName = Selection.activeTransform.name;
                exporNameSuccess = false;
            }
            else if (!File.Exists(ContolTexFolder + Next + ".png"))
            {
                contolTexName = Next;
                exporNameSuccess = false;
            }

        }

        string path = ContolTexFolder + contolTexName + ".png";
        byte[] bytes = newMaskTex.EncodeToPNG();
        File.WriteAllBytes(path, bytes);//保存


        AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate);//导入资源
        //Contol贴图的导入设置
        TextureImporter textureIm = AssetImporter.GetAtPath(path) as TextureImporter;
        TextureImporterPlatformSettings texset = textureIm.GetDefaultPlatformTextureSettings();
        texset.format = TextureImporterFormat.RGBA32;
        //texset.maxTextureSize=512;
        textureIm.SetPlatformTextureSettings(texset);
        textureIm.isReadable = true;
        textureIm.anisoLevel = 9;
        textureIm.mipmapEnabled = false;
        textureIm.wrapMode = TextureWrapMode.Clamp;
        AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate);//刷新


        setContolTex(path);//设置Contol贴图

    }

    //设置Contol贴图
    void setContolTex(string peth)
    {
        Texture2D ControlTex = (Texture2D)AssetDatabase.LoadAssetAtPath(peth, typeof(Texture2D));
        Selection.activeTransform.gameObject.GetComponent<MeshRenderer>().sharedMaterial.SetTexture("_Control", ControlTex);
    }

    void Painter()
    {
        
        
        Transform CurrentSelect = Selection.activeTransform;
        MeshFilter temp = CurrentSelect.GetComponent<MeshFilter>();//获取当前模型的MeshFilter
        float orthographicSize = (brushSize * CurrentSelect.localScale.x) * (temp.sharedMesh.bounds.size.x / 200);//笔刷在模型上的正交大小
        MaskTex = (Texture2D)CurrentSelect.gameObject.GetComponent<MeshRenderer>().sharedMaterial.GetTexture("_Control");//从材质球中获取Control贴图

        brushSizeInPourcent = (int)Mathf.Round((brushSize * MaskTex.width) / 100);//笔刷在模型上的大小
        bool ToggleF = false;
        Event e = Event.current;//检测输入
        HandleUtility.AddDefaultControl(0);
        RaycastHit raycastHit = new RaycastHit();
        Ray terrain = HandleUtility.GUIPointToWorldRay(e.mousePosition);//从鼠标位置发射一条射线
        if (Physics.Raycast(terrain, out raycastHit, Mathf.Infinity, 1 << LayerMask.NameToLayer("ground")))//射线检测名为"ground"的层
        {
            Handles.color = new Color(1f, 1f, 0f, 1f);//颜色
            Handles.DrawWireDisc(raycastHit.point, raycastHit.normal, orthographicSize);//根据笔刷大小在鼠标位置显示一个圆

            //鼠标点击或按下并拖动进行绘制
            if ((e.type == EventType.MouseDrag &amp;&amp; e.alt == false &amp;&amp; e.control == false &amp;&amp; e.shift == false &amp;&amp; e.button == 0) || (e.type == EventType.MouseDown &amp;&amp; e.shift == false &amp;&amp; e.alt == false &amp;&amp; e.control == false &amp;&amp; e.button == 0 &amp;&amp; ToggleF == false))
            {
                //选择绘制的通道
                Color targetColor = new Color(1f, 0f, 0f, 0f);
                switch (selTex)
                {
                    case 0:
                        targetColor = new Color(1f, 0f, 0f, 0f);
                        break;
                    case 1:
                        targetColor = new Color(0f, 1f, 0f, 0f);
                        break;
                    case 2:
                        targetColor = new Color(0f, 0f, 1f, 0f);
                        break;
                    case 3:
                        targetColor = new Color(0f, 0f, 0f, 1f);
                        break;

                }

                //targetColor = new Color(0f, 0f, 0f, 0f);

                Vector2 pixelUV = raycastHit.textureCoord;

                //计算笔刷所覆盖的区域
                int PuX = Mathf.FloorToInt(pixelUV.x * MaskTex.width);
                int PuY = Mathf.FloorToInt(pixelUV.y * MaskTex.height);
                int x = Mathf.Clamp(PuX - brushSizeInPourcent / 2, 0, MaskTex.width - 1);
                int y = Mathf.Clamp(PuY - brushSizeInPourcent / 2, 0, MaskTex.height - 1);
                int width = Mathf.Clamp((PuX + brushSizeInPourcent / 2), 0, MaskTex.width) - x;
                int height = Mathf.Clamp((PuY + brushSizeInPourcent / 2), 0, MaskTex.height) - y;

                Color[] terrainBay = MaskTex.GetPixels(x, y, width, height, 0);//获取Control贴图被笔刷所覆盖的区域的颜色

                Texture2D TBrush = brushTex[selBrush] as Texture2D;//获取笔刷性状贴图
                float[] brushAlpha = new float[brushSizeInPourcent * brushSizeInPourcent];//笔刷透明度

                //根据笔刷贴图计算笔刷的透明度
                for (int i = 0; i < brushSizeInPourcent; i++)
                {
                    for (int j = 0; j < brushSizeInPourcent; j++)
                    {
                        brushAlpha[j * brushSizeInPourcent + i] = TBrush.GetPixelBilinear(((float)i) / brushSizeInPourcent, ((float)j) / brushSizeInPourcent).a;
                    }
                }

                //计算绘制后的颜色
                for (int i = 0; i < height; i++)
                {
                    for (int j = 0; j < width; j++)
                    {
                        int index = (i * width) + j;
                        float Stronger = brushAlpha[Mathf.Clamp((y + i) - (PuY - brushSizeInPourcent / 2), 0, brushSizeInPourcent - 1) * brushSizeInPourcent + Mathf.Clamp((x + j) - (PuX - brushSizeInPourcent / 2), 0, brushSizeInPourcent - 1)] * brushStronger;

                        terrainBay[index] = Color.Lerp(terrainBay[index], targetColor, Stronger);
                    }
                }
                Undo.RegisterCompleteObjectUndo(MaskTex, "meshPaint");//保存历史记录以便撤销

                MaskTex.SetPixels(x, y, width, height, terrainBay, 0);//把绘制后的Control贴图保存起来
                MaskTex.Apply();
                ToggleF = true;
            }

              if(e.type == EventType.MouseUp &amp;&amp; e.alt == false &amp;&amp; e.button == 0 &amp;&amp; ToggleF == true)
            {

                SaveTexture();//绘制结束保存Control贴图
                ToggleF = false;
            }
        }
    }
    public void SaveTexture()
    {
        var path = AssetDatabase.GetAssetPath(MaskTex);
        var bytes = MaskTex.EncodeToPNG();
        File.WriteAllBytes(path, bytes);
        AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate);//刷新
    }
}

Shader模块

Shader "zcxshaderlibrary/texBlendWithBump"
{
    Properties
    {
        _SpecularColor("SpecularColor",Color)=(1,1,1,1)
        _Smoothness("Smoothness",range(0,20))=10
        _Cutoff("Cutoff",float)=0.5
        [Space(10)][Header(Layer)]
        [Space(30)][Header(Layer1Map)]
        _Splat0 ("Layer 1(RGBA)",2D) = "white"{}
        _Splat0_Color("_Splat1 Color",Color) = (1,1,1,1)
        _BumpSplat0 ("Layer 1 Normal(Bump)", 2D) = "Bump" {}
        _BumpSplat0Scale("Layer 1 Normal Scale",float)=1
        [Space(30)][Header(Layer2Map)]
        _Splat1 ("Layer 2(RGBA)", 2D) = "white" {}
        _Splat1_Color("_Splat2 Color",Color) = (1,1,1,1)
        _BumpSplat1 ("Layer 2 Normal(Bump)", 2D) = "Bump" {}
        _BumpSplat1Scale("Layer 2 Normal Scale",float)=1
        [Space(30)][Header(Layer3Map)]
        _Splat2 ("Layer 3(RGBA)", 2D) = "white" {}
        _Splat2_Color("_Splat3 Color",Color) = (1,1,1,1)
        _BumpSplat2 ("Layer 3 Normal(Bump)", 2D) = "Bump" {}
        _BumpSplat2Scale("Layer 3 Normal Scale",float)=1
        [Space(30)][Header(Layer4Map)]
        _Splat3 ("Layer 4(RGBA)", 2D) = "white" {}
        _Splat3_Color("_Splat4 Color",Color) = (1,1,1,1)
        _BumpSplat3 ("Layer 4 Normal(Bump)", 2D) = "Bump" {}
        _BumpSplat3Scale("Layer 4 Normal Scale",float)=1

        [Space(30)][Header(Blend Texture)]
        _Control ("Control (RGBA)", 2D) = "white" {}
        _Weight("Blend Weight" , Range(0.001,1)) = 0.2 
        
    }
    SubShader
    {
        Tags
        {
            "RenderPipeline"="UniversalPipeline"
            "Queue"="Geometry"
            "RenderType"="Opaque"
        }

        HLSLINCLUDE
        #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
        CBUFFER_START(UnityPerMaterial)
        
        float4 _BaseColor;
        float4 _Splat0_ST;
        float4 _Splat0_Color;
        float4 _SpecularColor;
        float4 _BumpSplat0_ST;
        float4 _Splat1_ST;
        float4 _Splat1_Color;
        float4 _BumpSplat1_ST;
        float4 _Splat2_ST;
        float4 _Splat2_Color;
        float4 _BumpSplat2_ST;
        float4 _Splat3_ST;
        float4 _Splat3_Color;
        float4 _BumpSplat3_ST;
        float4 _Control_ST;

        //float _Control;
        float _Weight;

        float _Smoothness;
        float _Cutoff;
        
        float _BumpSplat0Scale;
        float _BumpSplat1Scale;
        float _BumpSplat2Scale;
        float _BumpSplat3Scale;


        TEXTURE2D(_Splat0);
        SAMPLER(sampler_Splat0);  
        TEXTURE2D(_Splat1);
        SAMPLER(sampler_Splat1);  
        TEXTURE2D(_Splat2);
        SAMPLER(sampler_Splat2);  
        TEXTURE2D(_Splat3);
        SAMPLER(sampler_Splat3);  
        TEXTURE2D(_Control);
        SAMPLER(sampler_Control); 
        TEXTURE2D(_BumpSplat0);
        SAMPLER(sampler_BumpSplat0);
        TEXTURE2D(_BumpSplat1);
        SAMPLER(sampler_BumpSplat1);
        TEXTURE2D(_BumpSplat2);
        SAMPLER(sampler_BumpSplat2);  
        TEXTURE2D(_BumpSplat3);
        SAMPLER(sampler_BumpSplat3);  





        CBUFFER_END
        float4 _BaseMap_ST;
        
        ENDHLSL
    

        Pass
        {
            Name "URPSimpleLit" 
            Tags{"LightMode"="UniversalForward"}

            HLSLPROGRAM            
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"

            #pragma vertex vert
            #pragma fragment frag

            

            struct Attributes
            {
                float4 positionOS : POSITION;
                float4 normalOS : NORMAL;
                float4 tangentOS  : TANGENT;
                float2 uv0 : TEXCOORD0;
                float2 uv1 : TEXCOORD1;
                float2 uv2 : TEXCOORD2;
                float2 uv3 : TEXCOORD3;
                float2 uv4 : TEXCOORD4;
            };
            struct Varings
            {
                float4 positionCS : SV_POSITION;
                float2 uv0 : TEXCOORD0;
                float2 uv1 : TEXCOORD1;
                float2 uv2 : TEXCOORD2;
                float2 uv3 : TEXCOORD3;
                float2 uv4 : TEXCOORD4;
                float3 positionWS : TEXCOORD5;
                float3 viewDirWS : TEXCOORD6;
                float3 normalWS : NORMAL_WS;
                //float3 normalWS1   : NORMAL_WS;
                float4 tangentWS  : TANGENT_WS;
            };
            
  
            
            inline float4 Blend(float depth1 ,float depth2,float depth3,float depth4 , float4 control) 
            {
                float4 blend ;
                
                blend.r =depth1 * control.r;
                blend.g =depth2 * control.g;
                blend.b =depth3 * control.b;
                blend.a =depth4 * control.a;
                
                float ma = max(blend.r, max(blend.g, max(blend.b, blend.a)));
                blend = max(blend - ma +_Weight , 0) * control;
                return blend/(blend.r + blend.g + blend.b + blend.a);
            }

            Varings vert(Attributes IN)
            {
                Varings OUT;
                VertexPositionInputs positionInputs = GetVertexPositionInputs(IN.positionOS.xyz);
                VertexNormalInputs normalInputs = GetVertexNormalInputs(IN.normalOS.xyz);
                real sign = IN.tangentOS.w * GetOddNegativeScale();
                OUT.positionCS = positionInputs.positionCS;
                OUT.positionWS = positionInputs.positionWS;
                OUT.viewDirWS = GetCameraPositionWS() - positionInputs.positionWS;
                OUT.normalWS = normalInputs.normalWS;
                OUT.tangentWS  = real4(normalInputs.tangentWS, sign);
                OUT.uv0=TRANSFORM_TEX(IN.uv0,_Splat0);
                OUT.uv1=TRANSFORM_TEX(IN.uv1,_Splat1);
                OUT.uv2=TRANSFORM_TEX(IN.uv2,_Splat2);
                OUT.uv3=TRANSFORM_TEX(IN.uv3,_Splat3);
                OUT.uv4=TRANSFORM_TEX(IN.uv4,_Control);
                return OUT;
            }
            
            float4 frag(Varings IN):SV_Target
            {   
                //
                float4 splat_control = SAMPLE_TEXTURE2D(_Control, sampler_Control, IN.uv4);
                //纹理贴图
                float4 lay1 = SAMPLE_TEXTURE2D(_Splat0, sampler_Splat0, IN.uv0);     
                float4 lay2 = SAMPLE_TEXTURE2D(_Splat1, sampler_Splat1, IN.uv1);
                float4 lay3 = SAMPLE_TEXTURE2D(_Splat2, sampler_Splat2, IN.uv2);
                float4 lay4 = SAMPLE_TEXTURE2D(_Splat3, sampler_Splat3, IN.uv3); 

                //Bump贴图
                float3 nor1TS = UnpackNormalScale(SAMPLE_TEXTURE2D (_BumpSplat0, sampler_BumpSplat0,IN.uv0),_BumpSplat0Scale);
                float3 nor2TS = UnpackNormalScale(SAMPLE_TEXTURE2D (_BumpSplat1, sampler_BumpSplat1,IN.uv1),_BumpSplat1Scale);
                float3 nor3TS = UnpackNormalScale(SAMPLE_TEXTURE2D (_BumpSplat2, sampler_BumpSplat2,IN.uv2),_BumpSplat2Scale);
                float3 nor4TS = UnpackNormalScale(SAMPLE_TEXTURE2D (_BumpSplat3, sampler_BumpSplat3,IN.uv3),_BumpSplat3Scale);



                //BaseColor附加到各个图层
                lay1.rgb*=lay1.rgb*_Splat0_Color.rgb;
                lay2.rgb*=lay2.rgb*_Splat1_Color.rgb;
                lay3.rgb*=lay3.rgb*_Splat2_Color.rgb;
                lay4.rgb*=lay4.rgb*_Splat3_Color.rgb;
                
                

                //Normal计算
                real sgn = IN.tangentWS.w;
                real3 bitangent = sgn * cross(IN.normalWS.xyz, IN.tangentWS.xyz);
                real3 nor1WS = mul(nor1TS, real3x3(IN.tangentWS.xyz, bitangent.xyz, IN.normalWS.xyz));
                real3 nor2WS = mul(nor2TS, real3x3(IN.tangentWS.xyz, bitangent.xyz, IN.normalWS.xyz));
                real3 nor3WS = mul(nor3TS, real3x3(IN.tangentWS.xyz, bitangent.xyz, IN.normalWS.xyz));
                real3 nor4WS = mul(nor4TS, real3x3(IN.tangentWS.xyz, bitangent.xyz, IN.normalWS.xyz)); // 转换至世界空间
                //计算混合
                float4 blend = Blend(lay1.a,lay2.a,lay3.a,lay4.a,splat_control);
                float3 blendNor = nor1WS*blend.r+nor2WS*blend.g+nor3WS*blend.b+nor4WS*blend.a;

                //计算主光
                Light light = GetMainLight();
                float3 diffuse = LightingLambert(light.color, light.direction, IN.normalWS);
                float3 specular = LightingSpecular(light.color, light.direction, normalize(blendNor), normalize(IN.viewDirWS), _SpecularColor, _Smoothness);

                //计算附加光照
                uint pixelLightCount = GetAdditionalLightsCount();
                for (uint lightIndex = 0; lightIndex < pixelLightCount; ++lightIndex)
                {
                    Light light = GetAdditionalLight(lightIndex, IN.positionWS);
                    diffuse += LightingLambert(light.color, light.direction, IN.normalWS);
                    specular += LightingSpecular(light.color, light.direction, normalize(blendNor), normalize(IN.viewDirWS), _SpecularColor, _Smoothness);
                }


                

                float3 basecolor=lay1.rgb *blend.r  + lay2.rgb* blend.g + lay3.rgb * blend.b + lay4.rgb * blend.a;//混合
                
                
                float4 ambient=float4(SampleSH(blendNor), 1.0)*(basecolor,0);

                float3 diff=saturate(dot(light.direction,blendNor))*basecolor;

                real3 viewDirectionWS = SafeNormalize(GetCameraPositionWS() - IN.positionWS); // safe防止分母为0
                real3 h = SafeNormalize(viewDirectionWS + light.direction);
                float3 color=diff*diffuse+specular+ambient.rgb;
                clip(lay1.a*lay2.a*lay3.a*lay4.a-_Cutoff);
                return float4(color,0);
            }
            ENDHLSL            
        }

        
          Pass
        {
            Name "ShadowCaster"
            Tags{"LightMode" = "ShadowCaster"}

            ZWrite On
            ZTest LEqual
            Cull[_Cull]

            HLSLPROGRAM
            // Required to compile gles 2.0 with standard srp library
            #pragma prefer_hlslcc gles
            #pragma exclude_renderers d3d11_9x
            #pragma target 2.0

            // -------------------------------------
            // Material Keywords
            #pragma shader_feature _ALPHATEST_ON
            #pragma shader_feature _GLOSSINESS_FROM_BASE_ALPHA

            //--------------------------------------
            // GPU Instancing
            #pragma multi_compile_instancing

            #pragma vertex ShadowPassVertex
            #pragma fragment ShadowPassFragment


            //由于这段代码中声明了自己的CBUFFER,与我们需要的不一样,所以我们注释掉他
            //#include "Packages/com.unity.render-pipelines.universal/Shaders/SimpleLitInput.hlsl"
            //它还引入了下面2个hlsl文件
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/SurfaceInput.hlsl"
            #include "Packages/com.unity.render-pipelines.universal/Shaders/ShadowCasterPass.hlsl"
            ENDHLSL
        }

    }
}

RenderFeatureToggler

最近在搞renderFeature,写了个脚本来控制他的开关

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering.Universal;
 
[System.Serializable]
public struct RenderFeatureToggle
{
    public ScriptableRendererFeature feature;
    public bool isEnabled;
}
 
[ExecuteAlways]
public class RenderFeatureToggler : MonoBehaviour
{
    [SerializeField]
    private List<RenderFeatureToggle> renderFeatures = new List<RenderFeatureToggle>();
    [SerializeField]
    private UniversalRenderPipelineAsset pipelineAsset;
 
    private void Update()
    {
        foreach (RenderFeatureToggle toggleObj in renderFeatures)
        {
            toggleObj.feature.SetActive(toggleObj.isEnabled);
        }
    }
}

改一下可以用脚本控制

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering.Universal;

[System.Serializable]
public struct RenderFeatureToggle
{
    public ScriptableRendererFeature feature;
    public bool isEnabled;
}

[ExecuteAlways]
public class RenderFeatureToggler : MonoBehaviour
{
    [SerializeField]
    private List<RenderFeatureToggle> renderFeatures = new List<RenderFeatureToggle>();
    [SerializeField]
    private UniversalRenderPipelineAsset pipelineAsset;

    private void Update()
    {
        //foreach (RenderFeatureToggle toggleObj in renderFeatures)
        //{
        //    toggleObj.feature.SetActive(toggleObj.isEnabled);
        //}
    }

    public void OpenRenderFeatureToggle()
    {
        foreach (RenderFeatureToggle toggleObj in renderFeatures)
        {
            toggleObj.feature.SetActive(true);
        }
    }

    public void OffRenderFeatureToggle()
    {
        foreach (RenderFeatureToggle toggleObj in renderFeatures)
        {
            toggleObj.feature.SetActive(false);
        }
    }

}

物体遮挡剔除处理

先看下效果

视频有压缩,效果有点不对,下面是正确的效果

先上两段代码

using System;
using System.Collections.Generic;
using UnityEngine;

public class FadingObject : MonoBehaviour, IEquatable<FadingObject>
{
    public List<Renderer> Renderers = new List<Renderer>();
    public Vector3 Position;
    public List<Material> Materials = new List<Material>();
    [HideInInspector]
    public float InitialAlpha;
    //public float InitialFadeScale;


    private void Awake()
    {
        Position = transform.position;

        if (Renderers.Count == 0)
        {
            Renderers.AddRange(GetComponentsInChildren<Renderer>());
        }
        foreach(Renderer renderer in Renderers)
        {
            Materials.AddRange(renderer.materials);
        }

        InitialAlpha = Materials[0].color.a;
        Materials[0].SetFloat("_DitherThreshold", 1f);
    }

    public bool Equals(FadingObject other)
    {
        return Position.Equals(other.Position);
    }

    public override int GetHashCode()
    {
        return Position.GetHashCode();
    }
}


using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class FadeObjectBlockingObject : MonoBehaviour
{
    [SerializeField]
    private LayerMask LayerMask;
    [SerializeField]
    private Transform Target;
    [SerializeField]
    private Camera Camera;
    [SerializeField]
    [Range(0, 1f)]
    private float FadedAlpha = 0.33f;
    [SerializeField]
    private bool RetainShadows = true;
    [SerializeField]
    private Vector3 TargetPositionOffset = Vector3.up;
    [SerializeField]
    private float FadeSpeed = 1;

    [Header("Read Only Data")]
    [SerializeField]
    private List<FadingObject> ObjectsBlockingView = new List<FadingObject>();
    private Dictionary<FadingObject, Coroutine> RunningCoroutines = new Dictionary<FadingObject, Coroutine>();

    private RaycastHit[] Hits = new RaycastHit[10];

    private void OnEnable()
    {
        StartCoroutine(CheckForObjects());
    }

    private IEnumerator CheckForObjects()
    {
        while (true)
        {
            int hits = Physics.RaycastNonAlloc(
                Camera.transform.position,
                (Target.transform.position + TargetPositionOffset - Camera.transform.position).normalized,
                Hits,
                Vector3.Distance(Camera.transform.position, Target.transform.position + TargetPositionOffset),
                LayerMask
            );

            if (hits > 0)
            {
                for (int i = 0; i < hits; i++)
                {
                    FadingObject fadingObject = GetFadingObjectFromHit(Hits[i]);

                    if (fadingObject != null &amp;&amp; !ObjectsBlockingView.Contains(fadingObject))
                    {
                        if (RunningCoroutines.ContainsKey(fadingObject))
                        {
                            if (RunningCoroutines[fadingObject] != null)
                            {
                                StopCoroutine(RunningCoroutines[fadingObject]);
                            }

                            RunningCoroutines.Remove(fadingObject);
                        }

                        RunningCoroutines.Add(fadingObject, StartCoroutine(FadeObjectOut(fadingObject)));
                        ObjectsBlockingView.Add(fadingObject);
                    }
                }
            }

            FadeObjectsNoLongerBeingHit();

            ClearHits();

            yield return null;
        }
    }

    private void FadeObjectsNoLongerBeingHit()
    {
        List<FadingObject> objectsToRemove = new List<FadingObject>(ObjectsBlockingView.Count);

        foreach (FadingObject fadingObject in ObjectsBlockingView)
        {
            bool objectIsBeingHit = false;
            for (int i = 0; i < Hits.Length; i++)
            {
                FadingObject hitFadingObject = GetFadingObjectFromHit(Hits[i]);
                if (hitFadingObject != null &amp;&amp; fadingObject == hitFadingObject)
                {
                    objectIsBeingHit = true;
                    break;
                }
            }

            if (!objectIsBeingHit)
            {
                if (RunningCoroutines.ContainsKey(fadingObject))
                {
                    if (RunningCoroutines[fadingObject] != null)
                    {
                        StopCoroutine(RunningCoroutines[fadingObject]);
                    }
                    RunningCoroutines.Remove(fadingObject);
                }

                RunningCoroutines.Add(fadingObject, StartCoroutine(FadeObjectIn(fadingObject)));
                 objectsToRemove.Add(fadingObject);
            }
        }

        foreach(FadingObject removeObject in objectsToRemove)
        {
            ObjectsBlockingView.Remove(removeObject);
        }
    }

    private IEnumerator FadeObjectOut(FadingObject FadingObject)
    {
        foreach (Material material in FadingObject.Materials)
        {
            material.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha);
            material.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
            material.SetInt("_ZWrite", 0);
            material.SetInt("_Surface", 1);

            material.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent;

            material.SetShaderPassEnabled("DepthOnly", false);
            material.SetShaderPassEnabled("SHADOWCASTER", RetainShadows);

            material.SetOverrideTag("RenderType", "Transparent");

            material.EnableKeyword("_SURFACE_TYPE_TRANSPARENT");
            material.EnableKeyword("_ALPHAPREMULTIPLY_ON");
        }

        float time = 0;

        while (FadingObject.Materials[0].color.a > FadedAlpha)
        {
            foreach (Material material in FadingObject.Materials)
            {
                if (material.HasProperty("_BaseColor"))
                {
                    float a = Mathf.Lerp(FadingObject.InitialAlpha, FadedAlpha, time * FadeSpeed);
                    material.color = new Color(
                        material.color.r,
                        material.color.g,
                        material.color.b,
                        a
                        
                    );

                    //print("_1");
                    //Setting fade value
                    material.SetFloat("_DitherThreshold", a);
                }
            }

            time += Time.deltaTime;
            yield return null;
        }

        if (RunningCoroutines.ContainsKey(FadingObject))
        {
            StopCoroutine(RunningCoroutines[FadingObject]);
            RunningCoroutines.Remove(FadingObject);
        }
    }

    private IEnumerator FadeObjectIn(FadingObject FadingObject)
    {
        float time = 0;

        while (FadingObject.Materials[0].color.a < FadingObject.InitialAlpha)
        {
            foreach (Material material in FadingObject.Materials)
            {
                if (material.HasProperty("_BaseColor"))
                {
                    float a = Mathf.Lerp(FadedAlpha, FadingObject.InitialAlpha, time * FadeSpeed);

                    material.color = new Color(
                        material.color.r,
                        material.color.g,
                        material.color.b,
                        a
                    );

                    //Setting fade value
                    material.SetFloat("_DitherThreshold", a);

                }
            }

            time += Time.deltaTime;
            yield return null;
        }

        foreach (Material material in FadingObject.Materials)
        {
            material.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.One);
            material.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.Zero);
            material.SetInt("_ZWrite", 1);
            material.SetInt("_Surface", 0);

            material.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Geometry;

            material.SetShaderPassEnabled("DepthOnly", true);
            material.SetShaderPassEnabled("SHADOWCASTER", true);

            material.SetOverrideTag("RenderType", "Opaque");

            material.DisableKeyword("_SURFACE_TYPE_TRANSPARENT");
            material.DisableKeyword("_ALPHAPREMULTIPLY_ON");
        }

        if (RunningCoroutines.ContainsKey(FadingObject))
        {
            StopCoroutine(RunningCoroutines[FadingObject]);
            RunningCoroutines.Remove(FadingObject);
        }
    }

    private void ClearHits()
    {
        System.Array.Clear(Hits, 0, Hits.Length);
    }

    private FadingObject GetFadingObjectFromHit(RaycastHit Hit)
    {
        return Hit.collider != null ? Hit.collider.GetComponent<FadingObject>() : null;
    }

}


放一个自动抓取场景中的物体然后再实现遮挡处理的脚本,这个主要是用在main camera需要从全局调用的场景

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class FadeObjectBlockingObject : MonoBehaviour
{
    [SerializeField]
    private LayerMask LayerMask;
    [SerializeField]
    private Transform Target;
    [SerializeField]
    private Camera Camera;
    [SerializeField]
    [Range(0, 1f)]
    private float FadedAlpha = 0.33f;
    [SerializeField]
    private bool RetainShadows = true;
    [SerializeField]
    private Vector3 TargetPositionOffset = Vector3.up;
    [SerializeField]
    private float FadeSpeed = 1;

    [Header("Read Only Data")]
    [SerializeField]
    private List<FadingObject> ObjectsBlockingView = new List<FadingObject>();
    private Dictionary<FadingObject, Coroutine> RunningCoroutines = new Dictionary<FadingObject, Coroutine>();

    private RaycastHit[] Hits = new RaycastHit[10];
    void Start()
    {
        this.Camera = Camera.main;
        this.Target = this.transform;
        if (this.transform.parent.name == "ePlayer")
        {
            StartCoroutine(CheckForObjects());    
        }
    }

    private void OnEnable()
    {
        
    }

    private IEnumerator CheckForObjects()
    {
        while (true)
        {
            int hits = Physics.RaycastNonAlloc(
                Camera.transform.position,
                (Target.transform.position + TargetPositionOffset - Camera.transform.position).normalized,
                Hits,
                Vector3.Distance(Camera.transform.position, Target.transform.position + TargetPositionOffset),
                LayerMask
            );

            // Gizmos.DrawLine(Camera.transform.position, Target.transform.position);
            // Debug.DrawLine(Camera.transform.position, Target.transform.position , Color.red, 0.5f ); 
            if (hits > 0)
            {
                for (int i = 0; i < hits; i++)
                {
                    FadingObject fadingObject = GetFadingObjectFromHit(Hits[i]);

                    if (fadingObject != null &amp;&amp; !ObjectsBlockingView.Contains(fadingObject))
                    {
                        if (RunningCoroutines.ContainsKey(fadingObject))
                        {
                            if (RunningCoroutines[fadingObject] != null)
                            {
                                StopCoroutine(RunningCoroutines[fadingObject]);
                            }

                            RunningCoroutines.Remove(fadingObject);
                        }

                        RunningCoroutines.Add(fadingObject, StartCoroutine(FadeObjectOut(fadingObject)));
                        ObjectsBlockingView.Add(fadingObject);
                    }
                }
            }

            FadeObjectsNoLongerBeingHit();

            ClearHits();

            yield return null;
        }
    }

    private void FadeObjectsNoLongerBeingHit()
    {
        List<FadingObject> objectsToRemove = new List<FadingObject>(ObjectsBlockingView.Count);

        foreach (FadingObject fadingObject in ObjectsBlockingView)
        {
            bool objectIsBeingHit = false;
            for (int i = 0; i < Hits.Length; i++)
            {
                FadingObject hitFadingObject = GetFadingObjectFromHit(Hits[i]);
                if (hitFadingObject != null &amp;&amp; fadingObject == hitFadingObject)
                {
                    objectIsBeingHit = true;
                    break;
                }
            }

            if (!objectIsBeingHit)
            {
                if (RunningCoroutines.ContainsKey(fadingObject))
                {
                    if (RunningCoroutines[fadingObject] != null)
                    {
                        StopCoroutine(RunningCoroutines[fadingObject]);
                    }
                    RunningCoroutines.Remove(fadingObject);
                }

                RunningCoroutines.Add(fadingObject, StartCoroutine(FadeObjectIn(fadingObject)));
                 objectsToRemove.Add(fadingObject);
            }
        }

        foreach(FadingObject removeObject in objectsToRemove)
        {
            ObjectsBlockingView.Remove(removeObject);
        }
    }

    private IEnumerator FadeObjectOut(FadingObject FadingObject)
    {
        foreach (Material material in FadingObject.Materials)
        {
            material.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha);
            material.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
            material.SetInt("_ZWrite", 0);
            material.SetInt("_Surface", 1);

            material.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Transparent;

            material.SetShaderPassEnabled("DepthOnly", false);
            material.SetShaderPassEnabled("SHADOWCASTER", RetainShadows);

            material.SetOverrideTag("RenderType", "Transparent");

            material.EnableKeyword("_SURFACE_TYPE_TRANSPARENT");
            material.EnableKeyword("_ALPHAPREMULTIPLY_ON");
        }

        float time = 0;

        while (FadingObject.Materials[0].color.a > FadedAlpha)
        {
            foreach (Material material in FadingObject.Materials)
            {
                if (material.HasProperty("_BaseColor"))
                {
                    float a = Mathf.Lerp(FadingObject.InitialAlpha, FadedAlpha, time * FadeSpeed);
                    material.color = new Color(
                        material.color.r,
                        material.color.g,
                        material.color.b,
                        a
                        
                    );

                    //print("_1");
                    //Setting fade value
                    material.SetFloat("_DitherThreshold", a);
                }
            }

            time += Time.deltaTime;
            yield return null;
        }

        if (RunningCoroutines.ContainsKey(FadingObject))
        {
            StopCoroutine(RunningCoroutines[FadingObject]);
            RunningCoroutines.Remove(FadingObject);
        }
    }

    private IEnumerator FadeObjectIn(FadingObject FadingObject)
    {
        float time = 0;

        while (FadingObject.Materials[0].color.a < FadingObject.InitialAlpha)
        {
            foreach (Material material in FadingObject.Materials)
            {
                if (material.HasProperty("_BaseColor"))
                {
                    float a = Mathf.Lerp(FadedAlpha, FadingObject.InitialAlpha, time * FadeSpeed);

                    material.color = new Color(
                        material.color.r,
                        material.color.g,
                        material.color.b,
                        a
                    );

                    //Setting fade value
                    material.SetFloat("_DitherThreshold", a);

                }
            }

            time += Time.deltaTime;
            yield return null;
        }

        foreach (Material material in FadingObject.Materials)
        {
            material.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.One);
            material.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.Zero);
            material.SetInt("_ZWrite", 1);
            material.SetInt("_Surface", 0);

            material.renderQueue = (int)UnityEngine.Rendering.RenderQueue.Geometry;

            material.SetShaderPassEnabled("DepthOnly", true);
            material.SetShaderPassEnabled("SHADOWCASTER", true);

            material.SetOverrideTag("RenderType", "Opaque");

            material.DisableKeyword("_SURFACE_TYPE_TRANSPARENT");
            material.DisableKeyword("_ALPHAPREMULTIPLY_ON");
        }

        if (RunningCoroutines.ContainsKey(FadingObject))
        {
            StopCoroutine(RunningCoroutines[FadingObject]);
            RunningCoroutines.Remove(FadingObject);
        }
    }

    private void ClearHits()
    {
        System.Array.Clear(Hits, 0, Hits.Length);
    }

    private FadingObject GetFadingObjectFromHit(RaycastHit Hit)
    {
        return Hit.collider != null ? Hit.collider.GetComponent<FadingObject>() : null;
    }

}

在Unity下实现Normal Smooth以解决描边断裂的问题

啥也不说先上代码

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using System.IO;
using Unity.Jobs;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;

public class OutLineBaker : AssetPostprocessor
{
    static bool bExecuting = false;
    [MenuItem("Tools/BakeNormal", false, 100)]
    
    public static void BakeNormal()
        {
        Object obj = Selection.activeObject;
        string path = AssetDatabase.GetAssetPath(obj);
        string ext = Path.GetExtension(path);

        if(ext == ".fbx")
        {
            bExecuting = true;
            string newPath = Path.GetDirectoryName(path) + "/copy_@@@" + Path.GetFileName(path);
            Debug.Log($"new Path= {newPath}");
            AssetDatabase.CopyAsset(path, newPath);
            //AssetDatabase.ImportAsset(newPath);


        }
        else
        {
            Debug.Log($"必须对.fbx文件进行此操作");
        }
        //Debug.Log($"EXT name = {ext}");

        }
    static void OnPostOver(string path,bool isCopy)
    {
        if (isCopy)
        {
            string srcPath = path.Replace("copy_@@@", "");
            Debug.Log($"复制体后处理后,本体的path={srcPath}");
            AssetDatabase.ImportAsset(srcPath);
        }
        else
        {
            Debug.Log($"处理完毕,关闭开关");
            bExecuting = false;
        }

    }
    void OnPreprocessModel()
    {
        if (!bExecuting) return;
        //Input Settings

        ModelImporter importer = assetImporter as ModelImporter;

        if (!assetImporter.assetPath.Contains("copy_@@@")) return;

        importer.importNormals = ModelImporterNormals.Calculate;
        importer.normalCalculationMode = ModelImporterNormalCalculationMode.AngleWeighted;
        importer.normalSmoothingAngle = 180f;


        importer.importAnimation = false;//no animation imported
        importer.materialImportMode = ModelImporterMaterialImportMode.None;//no material imported
        Debug.Log($"1");
    }

    void OnPostprocessModel(GameObject go)
    {
        if (!bExecuting) return;
        //AfterInputing Settings

        if(go.name.Contains("copy_@@@"))
        {
            //Debug.Log($"Enter the copy obj postprocess");
            OnPostOver(assetPath, true);
        }
        else
        {
           // Debug.Log($"Self");
            string copyPath = Path.GetDirectoryName(assetPath) + "/copy_@@@" + Path.GetFileName(assetPath);
            Debug.Log($"复制体的Path = {copyPath}");
            GameObject cGo = AssetDatabase.LoadAssetAtPath<GameObject>(copyPath);

            Dictionary<string, Mesh> dic_name2mesh_src = GetMesh(go);//原本模型的各个子Mesh,通过节点名索引
            Dictionary<string, Mesh> dic_name2mesh_copy = GetMesh(cGo);//平滑模型各个子Mesh,通过节点名索引

            foreach (var item in dic_name2mesh_src) 
            {
                item.Value.colors = GetColor(item.Value,dic_name2mesh_copy[item.Key]);
            }

            OnPostOver("", false);


        }

      
    }
    Dictionary<string,Mesh>GetMesh(GameObject go)
    {
        Dictionary<string, Mesh> dic = new();
        foreach (var item in go.GetComponentsInChildren<MeshFilter>())
        {
            string n = item.name.Replace("copy_@@@", "");
            dic.Add(n, item.sharedMesh);
        }

        if (dic.Count == 0)
        {
            foreach (var item in go.GetComponentsInChildren<SkinnedMeshRenderer>())
            {
                string n = item.name.Replace("copy_@@@", "");
                dic.Add(n, item.sharedMesh);
            }
        }

        return dic;
    }

    Color[] GetColor(Mesh srcMesh,Mesh smthMesh)
    {
        //按照顶点位置索引,烘焙信息
        int lenSrc = srcMesh.vertices.Length;
        int lenSmth = smthMesh.vertices.Length;
        int maxOverlap = 10;

        NativeArray<Vector3> arr_vert_smth = new(smthMesh.vertices,Allocator.Persistent);//Allocator为资源分配的方式,一般选择Persistent
        NativeArray<Vector3> arr_normal_smth = new(smthMesh.normals, Allocator.Persistent);

        NativeArray<UnsafeHashMap<Vector3, Vector3>> arr_vert2normal = new(maxOverlap, Allocator.Persistent);
        NativeArray<UnsafeHashMap<Vector3, Vector3>.ParallelWriter> arr_vert2normal_writer = new(maxOverlap, Allocator.Persistent);

        for (int i = 0; i < maxOverlap; i++)            
        {
            arr_vert2normal[i] = new UnsafeHashMap<Vector3, Vector3>(lenSmth, Allocator.Persistent);
            arr_vert2normal_writer[i] = arr_vert2normal[i].AsParallelWriter();
        }


        CollectNormalJob collectJob = new()
        {
            verts = arr_vert_smth,
            normals = arr_normal_smth,
            arr_vert2normal= arr_vert2normal_writer
        };

        JobHandle collectHandle = collectJob.Schedule(lenSmth, 128);
        collectHandle.Complete();




        NativeArray<Vector3> arr_vert_src = new(srcMesh.vertices, Allocator.Persistent);
        NativeArray<Vector3> arr_nor_src = new(srcMesh.normals, Allocator.Persistent);
        NativeArray<Vector4> arr_tgt_src = new(srcMesh.tangents, Allocator.Persistent);

        NativeArray<Color> arr_color = new(lenSrc, Allocator.Persistent);

        BakeNormalJob bakeJob = new()
        {
            normals = arr_nor_src,
            verts = arr_vert_src,
            tangents = arr_tgt_src,
            bakedNormals = arr_vert2normal,
            colors = arr_color

        };

        JobHandle bakeHandel = bakeJob.Schedule(lenSrc,128);
        bakeHandel.Complete();//烘焙完成

        Color[] cols = new Color[lenSrc];
        arr_color.CopyTo(cols);

        foreach (var item in arr_vert2normal)
        {
            item.Dispose();
        }
        arr_vert_smth.Dispose();
        arr_normal_smth.Dispose();
        arr_vert2normal.Dispose();
        arr_vert2normal_writer.Dispose();
        arr_vert_src.Dispose();
        arr_nor_src.Dispose();
        arr_tgt_src.Dispose();
        arr_color.Dispose();


        return cols;









    }

    struct CollectNormalJob : IJobParallelFor
    {
        [ReadOnly] public NativeArray<Vector3> verts;

        [ReadOnly] public NativeArray<Vector3> normals;


        //顶点-法线的映射
        [NativeDisableContainerSafetyRestriction]//解除安全锁定


        //顶点-法线
        public NativeArray<UnsafeHashMap<Vector3, Vector3>.ParallelWriter> arr_vert2normal;//NativeArray是一个平行字典,有一个安全封装,一般是无法写入的,所以需要一个.ParallelWriter的写入器
        


        public void Execute(int index)
        {
            //每次执行
            for (int i = 0; i < arr_vert2normal.Length; i++)
            {
                if (i==arr_vert2normal.Length)
                {
                    Debug.Log($"超出顶点数量");
                    break;
                }
                if (arr_vert2normal[i].TryAdd(verts[index], normals[index]))
                {
                    Debug.Log($"当前添加:顶点:{verts[index]},法线:{normals[index]}");
                    break;
                } 
            }
        }
    }

    struct BakeNormalJob : IJobParallelFor
    {
        //切线空间 切线 法线 顶点位置 烘焙好的法线 输出-颜色数组
        [ReadOnly] public NativeArray<Vector3> normals;
        [ReadOnly] public NativeArray<Vector3> verts;
        [ReadOnly] public NativeArray<Vector4> tangents;
        [ReadOnly][NativeDisableContainerSafetyRestriction] public NativeArray<UnsafeHashMap<Vector3, Vector3>> bakedNormals;
        public NativeArray<Color> colors;

        public void Execute(int index)
        {
            Vector3 newNormal = Vector3.zero;

            for (int i = 0; i < bakedNormals.Length; i++)
            {
                if (bakedNormals[i][verts[index]]!= Vector3.zero){
                    newNormal += bakedNormals[i][verts[index]];
                }
                else
                {
                    break;
                }
            }
            newNormal = newNormal.normalized;

            //tangent作为VEC4数值,里面的W存储的是+1或者-1,因为opengl于DX坐标一个左手一个右手,所以拿Z区分
            Vector3 bitangent = (Vector3.Cross(normals[index], tangents[index]) * tangents[index].w).normalized;

            Matrix4x4 tbn = new(
                tangents[index],
                bitangent,
                normals[index],
                Vector4.zero
                );

            tbn = tbn.transpose;

            Vector3 finalNormal = tbn.MultiplyVector(newNormal).normalized;

            Color col = new(

                finalNormal.x*0.5f+0.5f,
                finalNormal.y*0.5f+0.5f,
                finalNormal.z*0.5f+0.5f,
                1



                );

            colors[index] = col;
        }
    }
}

使用之前,现在unity中载入Collection,如果Unity版本大于2022,需要将UnsafeHashMap替代成UnsafeParallelHashMap