01先把模型拿出来
这次的起点是一份 AstraeOratio 的 RenderDoc 抓帧。画面里是换装房间,角色站在中间,右侧还有服装选择界面。我想先把这个机位还原出来,再接动作和可编辑的光照,方便继续改角色 Shader。
第一步先看模型。RDC 里能拿到这一帧实际参与绘制的顶点、索引和顶点属性,我把脸、身体、包和饰品拆出来,放进一个单独的 Unity 场景。这个阶段先用灰材质,主要看部件有没有漏、位置对不对,以及三角形朝向。
查看原图 · 1200 × 1200
查看原图 · 1200 × 1200房间、角色、界面资源来自原游戏,下面记录的是我在 Unity 2022.3 / URP 14 中转换资源、接回渲染和处理编辑器问题的过程。
02着色不能只接一张主贴图
模型的位置理顺之后,我开始接原 Shader。最初只画角色主颜色时,头发偏亮、衣服偏黑,边缘也很硬。原画面还包含模板、接触阴影、描边、房间合成和后处理;少接一个阶段,最后的颜色和遮挡都会变。
查看原图 · 1608 × 723我按抓帧里的顺序把这些阶段接回来,同时保留纹理的 mip、采样方式和材质常量。主颜色对上后,再接房间和屏幕合成,最后补界面。这样看到某个部位不对时,还能退回前面的阶段找原因。
查看原图 · 2400 × 1080
查看原图 · 2400 × 108003原骨架和动作要从资源里补
RDC 只给出某一帧。角色能站出来,不代表已经有可以播放的骨架和 AnimationClip。我接着从游戏资源包中找到这套服装对应的角色,提取骨骼树、绑定矩阵、蒙皮权重和表情,再转换成当前工程能加载的资产。
源资源使用 Unity 6000,工程是 Unity 2022.3,bundle 无法直接照搬。除了网格,动画里的 streamed、dense 和 constant 曲线也要分别处理。streamed 曲线保存的是分段多项式,我将它们转换为 AnimationCurve 的端点和切线;阶跃键需要单独保留,否则姿态切换会被插值抹平。
这套模型有 224 个节点、5 个蒙皮 Renderer 和 24 个脸部表情。先接的是服装出场 Intro 和待机 Idle,长度分别约 8.55 秒和 10 秒。
查看原图 · 1608 × 723动画接上后,还有一步容易漏:抓帧渲染器依然拿着静态网格。我的做法是让 Animator 驱动原模型,再把当前蒙皮结果送回主材质、模板、描边,以及深度和阴影绘制。
各部件使用的源对象矩阵不同,不能直接把同一份顶点数组塞给所有绘制。位置需要回到各自的输入空间,法线则走相应的逆转置。Face 的缩放也要计入 BakeMesh,少算或重复算都会让头部和身体对不上。
查看原图 · 2400 × 108004动起来之后,先解决 CPU 开销
原动作在房间里跑起来后,编辑器只有三十多帧。我先拆开看动画、网格更新和渲染提交,耗时最多的是把蒙皮顶点转回各个源绘制的坐标空间。
同一部件会被主颜色、模板、描边和辅助阶段反复使用。原实现每次重新拿顶点、分配数组、做坐标变换,很多工作是重复的。第一轮把列表和上传数组复用,资源读取挪到准备阶段;第二轮再按部件和相同对象矩阵共享转换结果。
这里不能用近似矩阵相等来合并。两份绘制只要变换略有不同,包、衣服或阴影就可能错位。共享的是确实相同的转换,各目标自己的通道和绘制仍然保留。
| 当时的本机编辑器观察 | 优化前 | 优化后 |
|---|---|---|
| 帧率 | 约 31–33 FPS | 约 88–91 FPS |
| 蒙皮网格桥接 | 约 21.9 ms | 约 7 ms |
| 渲染构建和提交 | 约 7.2 ms | 约 0.7 ms |
这组数据是在接实时阴影和新描边之前测的。优化时没有降低分辨率,也没有跳过动画帧;我用相同动作时间的截图检查脸、发辫、包和衣服,确认外观没有随优化改变。
05Scene 里也得能改同一套 Shader
另一个问题是在 Game 里看得到角色,切到 Scene 却看不到。原模型 Renderer 只负责提供蒙皮数据,Game 由自定义绘制链输出画面,普通 Scene 相机自然不会自动显示它。
我最初补了模型检视材质,让角色能被选中、旋转和查看骨骼。但这样 Scene 和 Game 的着色不同,改 Shader 还得反复切窗口。后面把 Scene 也接到原渲染链:共用 Shader 和当前动画网格,每台相机各自更新投影、常量、颜色与深度目标。
查看原图 · 2400 × 1080
查看原图 · 2400 × 1080
查看原图 · 1497 × 781固定抓帧里的矩阵不能直接用于自由相机。除了顶点投影,屏幕深度重建和视线方向也要一起更新。改这一层后,Scene 才能用来继续检查嘴唇、发丝和侧面的着色。
06旋转场景灯,角色为什么没反应
源 Shader 读取的是抓帧时的光照常量。Unity 的 Directional Light 虽然转了,角色仍然读取旧方向,环境光也没有进入原材质分支。
我把场景主光的方向、线性颜色和强度,以及环境 SH 接到角色常量里。原材质中不同部位有各自的方向偏移,我保留这些偏移,用主光旋转的增量带动它们;全部统一成一个方向会丢掉原来的卡通处理。
查看原图 · 2400 × 1080
查看原图 · 2400 × 1080环境光也保留 Flat、Gradient 和 Skybox 的编辑入口。它们进入原着色方程,之后改材质时才能看清哪一项造成了变化。
07亮度和阴影分开调整
接入场景光以后,角色又比参考亮了一截,脸上尤其明显。往回查有两个原因:默认天空环境增加了原帧没有的贡献,材质之间的光方向偏移也曾被统一掉。
我把默认主光恢复到参考方向,保留材质偏移,环境改为 Flat 黑色。这里的黑色是这个参考场景的起点,环境光参数仍然可以改。
查看原图 · 2400 × 1080
查看原图 · 2400 × 1080阴影则不能一直使用抓帧里的固定投影。房间原本有两级 CSM,我让它随当前相机、主光和动画模型重新生成,房间与角色都提交新的投影网格。软阴影沿用原来的九点 PCF。
角色原来主要使用自己的接触阴影,没有完整接收房间 CSM。直接加满房间阴影,室内角色就会过黑,所以我把角色受影强度单独留成参数,参考默认使用 0.02。它只影响直接光,环境项和自发光继续走自己的路径。
查看原图 · 2400 × 1080
查看原图 · 1608 × 723展开实现 · AstraSceneMainShadow
Assets/AstraeOratioPreview/Shaders/Character/AstraeOratioSceneShadowInput.hlsl
float AstraSceneMainShadow(float2 rasterPosition)
{
int2 dimensions = max(int2(_AstraSceneShadowParameters.yz), int2(1, 1));
int2 pixel = clamp(int2(rasterPosition), int2(0, 0), dimensions - 1);
// E367 and character draws share the same physical render-target pixel grid.
// SV_Position is already backend raster space. An extra UV Y flip is incorrect.
float mask = saturate(_AstraSceneShadowMask.Load(int3(pixel, 0)).r);
return 1.0f - saturate(_AstraSceneShadowParameters.w) * (1.0f - mask);
}08描边的段落感从投影里找
描边放大看时,发丝和衣服边缘有明显的断续感,嘴唇附近还会出现不自然的短线。我沿用了无限大项目里最后采用的投影描边思路,但保留这套角色自己的平滑法线、Alpha 宽度遮罩和材质宽度比例。
原公式把投影后的 XY 法线归一化。一个几乎朝向相机的法线,投到屏幕上本来很短,再归一化就被撑成同样宽度,内部轮廓因此显得生硬。新实现只在三维里归一化法线,保留投影缩短,朝向相机的部分自然少外扩一些。
float viewWidth = 2 * pixels / targetHeight
* abs(clipPosition.w) / abs(projectionY);
float3 displacement = normalize(viewNormal) * viewWidth;
宽度先按实际几何目标的像素数定义,再转换到视空间。XYZ 一起外扩,深度也随同一张膨胀表面变化;只改屏幕 XY、保留旧深度,会让遮挡关系出问题。
查看原图 · 2400 × 1080
查看原图 · 2400 × 1080页面上方可以拖动比较头部,也可以切到全图。描边目前仍是独立绘制,和本体使用同一份当前蒙皮结果,衣服、包和发辫运动时一起更新。
展开实现 · AstraProjectedOutline
Assets/AstraeOratioPreview/Shaders/Character/AstraeOratioOutlineInput.hlsl
float4 AstraProjectedOutline(float4 originalClip, float3 viewNormal, float sourceMaterialWidth,
float vertexAlpha, float4 projection0, float4 projection1, float4 projection2)
{
if (abs(originalClip.w) <= 0.000001f) return originalClip;
float normalLengthSquared = dot(viewNormal, viewNormal);
if (normalLengthSquared <= 0.000000000001f) return originalClip;
// Normalize in 3D only. Never inflate a tiny XY projection to full width.
viewNormal *= rsqrt(normalLengthSquared);
float mask = clamp((vertexAlpha - 0.00999999977648258209228515625f) *
1.01010096073150634765625f, 0.0f, 1.0f);
// E464's source width .5 is the one-pixel reference. Keep all material ratios.
float pixels = max(_AstraOutlineParameters.y, 0.0f) * max(sourceMaterialWidth, 0.0f) * 2.0f * mask;
float ndcWidth = 2.0f * pixels / max(_AstraOutlineParameters.w, 1.0f);
float viewWidth = ndcWidth * abs(originalClip.w) / max(abs(projection1.y), 0.000001f);
float3 displacement = viewNormal * viewWidth;
// Linear projection of the displacement preserves the original VP jitter,
// while XYZ and depth describe the same inflated surface. Also works in ortho.
float4 expanded = originalClip + projection0 * displacement.x +
projection1 * displacement.y + projection2 * displacement.z;
if (abs(expanded.w) <= 0.000001f) return originalClip;
return float4((expanded.xyz / expanded.w) * originalClip.w, originalClip.w);
}09抗锯齿要落在真正画模型的目标上
描边几何修完,斜边仍然能看到阶梯。这个渲染器自己创建颜色和深度目标,原来明确使用单采样,所以只在 URP 设置里打开 MSAA,实际画角色的目标并没有改变。
我把主几何颜色和深度/模板附件都改为 4×,对应绘制共用逐采样深度和模板。后面的屏幕阶段读取前,再把颜色解析回单采样。深度取最近的有效样本,不能简单平均,否则边缘会得到一个实际不存在的表面。
查看原图 · 1608 × 723
查看原图 · 1608 × 723这次改善来自几何边缘的覆盖采样。无限大中用于静态预览的 Stable 时域过滤没有直接搬过来,动作场景还需要自己的运动矢量和前帧数据。
展开实现 · Frag
Assets/AstraeOratioPreview/Shaders/AstraeOratioCoverageResolve.shader
float Frag(float4 position : SV_Position) : SV_Depth
{
int2 pixel = int2(position.xy);
float depth = _AstraCoverageDepth.Load(pixel, 0);
[unroll] for (int sampleIndex = 1; sampleIndex < 4; sampleIndex++)
{
float sampleDepth = _AstraCoverageDepth.Load(pixel, sampleIndex);
depth = _AstraCoverageReversedZ != 0 ? max(depth, sampleDepth) : min(depth, sampleDepth);
}
return depth;
}10接下来继续补动态画面
现在可以在原房间里播放出场和待机,在 Scene 调整观察方向,并用场景灯检查角色材质。页面上方的视频展示当前版本;文中的旧截图保留当时的问题,方便看清每一步改了什么。
目前还没恢复原游戏的前帧蒙皮、连续运动矢量、抖动序列和动作相机,部分动画绑定也需要继续整理。头部高光、细节着色和完整后处理仍有差异。后面会先补动态输入,再回到同姿态的参考画面继续调整。
