Z赵崇逍Technical Artist / Shader Developer

RenderDoc → Unity URP

AstraeOratio角色与房间渲染还原

从模型开始,把抓帧里的角色动起来。

这一版接回了原出场和待机动作。下面放了当前场景录屏,也整理了灰模、着色、光照和描边的调整过程。

Unity 当前角色与房间Unity · 当前场景
工程环境Unity 2022.3 / URP 14 / D3D11
当前可用原动作 · Scene 调试 · 实时光影
继续调整动态拖影 · 头部细节 · 后处理

Abstract

技术摘要

这次还原以原游戏的 Face/Body Toon Shading Model 为基础,保留脸部 SDF 阴影输入和身体、头发的材质处理,将场景主光和环境 SH 接回原着色路径。模板与混合绘制沿用原顺序。阴影保留角色接触阴影,并补上随相机、主光和动画更新的两级 CSM;描边保留平滑法线和宽度遮罩,通过投影外扩与几何目标上的 4× MSAA 改善轮廓和斜边。原骨架、出场与待机动作同步驱动本体、描边和阴影网格,Scene 与 Game 共用 Shader。眉毛与刘海遮挡、头部高光和动态拖影仍需继续调整。

Unity / Original Animation

出场,再回到待机

下载视频

当前 Unity 场景的连续录屏,先播放原出场动作,再自动回到待机。保留原根运动,角色会靠近固定镜头;前帧蒙皮与运动矢量还没接完整,动作中仍能看到拖影和软化。

Outline / MSAA

描边修复前后

修复版原方案

两张均为待机 0 秒,同机位、同光照。原方案使用原描边和单采样,修复版使用投影描边与 4× MSAA。头部直接显示完整 PNG 的对应区域。

展开原尺寸并排对比

按原图像素显示;滚动或触摸滑动查看其他部位。两侧的位置同步。

原方案
原方案原尺寸画面
修复版
修复版原尺寸画面

01先把模型拿出来

这次的起点是一份 AstraeOratio 的 RenderDoc 抓帧。画面里是换装房间,角色站在中间,右侧还有服装选择界面。我想先把这个机位还原出来,再接动作和可编辑的光照,方便继续改角色 Shader。

第一步先看模型。RDC 里能拿到这一帧实际参与绘制的顶点、索引和顶点属性,我把脸、身体、包和饰品拆出来,放进一个单独的 Unity 场景。这个阶段先用灰材质,主要看部件有没有漏、位置对不对,以及三角形朝向。

从抓帧恢复的角色灰模查看原图 · 1200 × 1200
最早的灰模。这里还是抓帧时的固定姿态,骨架和动作还没有接入。
模型检视阶段的贴图效果查看原图 · 1200 × 1200
接上贴图后的模型检视。可以看清各部件,但此时使用的是检视材质,脸部和头发的着色还不是游戏里的结果。

房间、角色、界面资源来自原游戏,下面记录的是我在 Unity 2022.3 / URP 14 中转换资源、接回渲染和处理编辑器问题的过程。

02着色不能只接一张主贴图

模型的位置理顺之后,我开始接原 Shader。最初只画角色主颜色时,头发偏亮、衣服偏黑,边缘也很硬。原画面还包含模板、接触阴影、描边、房间合成和后处理;少接一个阶段,最后的颜色和遮挡都会变。

角色主颜色阶段查看原图 · 1608 × 723
只执行角色主着色的早期结果。头发高光、衣服暗部和边缘都需要继续检查,黑色背景也是隔离角色绘制时的输出。

我按抓帧里的顺序把这些阶段接回来,同时保留纹理的 mip、采样方式和材质常量。主颜色对上后,再接房间和屏幕合成,最后补界面。这样看到某个部位不对时,还能退回前面的阶段找原因。

角色与房间的早期完整重放查看原图 · 2400 × 1080
接回房间、后处理和 UI 后的固定帧结果。这张是动作接入前的阶段图。
原游戏抓帧参考查看原图 · 2400 × 1080
原游戏参考帧。后面播放动作时角色姿态会改变,固定帧着色和动态效果需要分开看。

03原骨架和动作要从资源里补

RDC 只给出某一帧。角色能站出来,不代表已经有可以播放的骨架和 AnimationClip。我接着从游戏资源包中找到这套服装对应的角色,提取骨骼树、绑定矩阵、蒙皮权重和表情,再转换成当前工程能加载的资产。

源资源使用 Unity 6000,工程是 Unity 2022.3,bundle 无法直接照搬。除了网格,动画里的 streamed、dense 和 constant 曲线也要分别处理。streamed 曲线保存的是分段多项式,我将它们转换为 AnimationCurve 的端点和切线;阶跃键需要单独保留,否则姿态切换会被插值抹平。

这套模型有 224 个节点、5 个蒙皮 Renderer 和 24 个脸部表情。先接的是服装出场 Intro 和待机 Idle,长度分别约 8.55 秒和 10 秒。

原出场动作接入角色独立场景查看原图 · 1608 × 723
动作刚接入时的独立角色场景,采样在出场约 2 秒。抬手、衣袖和包都已经跟着原骨架变化,房间尚未接到这条动态路径里。

动画接上后,还有一步容易漏:抓帧渲染器依然拿着静态网格。我的做法是让 Animator 驱动原模型,再把当前蒙皮结果送回主材质、模板、描边,以及深度和阴影绘制。

各部件使用的源对象矩阵不同,不能直接把同一份顶点数组塞给所有绘制。位置需要回到各自的输入空间,法线则走相应的逆转置。Face 的缩放也要计入 BakeMesh,少算或重复算都会让头部和身体对不上。

原动作接回换装房间查看原图 · 2400 × 1080
原动作接入当前房间后的待机截图。描边和实时光影还没有完成,先确认本体、包、头发和辅助绘制一起运动。当前版本的连续播放放在页面上方的视频里。

04动起来之后,先解决 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 和当前动画网格,每台相机各自更新投影、常量、颜色与深度目标。

对齐机位后的 Game 输出查看原图 · 2400 × 1080
Game:待机 0 秒,使用原相机。
对齐机位后的 Scene 输出查看原图 · 2400 × 1080
Scene:同一动作时间与相机,走同一套角色、阴影和后处理。两张都是这一阶段的直接渲染输出。
当前 Scene 的自由侧视角查看原图 · 1497 × 781
改完后可以在 Scene 转到侧面看模型,仍使用原 Shader。房间资源来自这次捕获,换到侧面也能看出原场景几何覆盖的范围。

固定抓帧里的矩阵不能直接用于自由相机。除了顶点投影,屏幕深度重建和视线方向也要一起更新。改这一层后,Scene 才能用来继续检查嘴唇、发丝和侧面的着色。

06旋转场景灯,角色为什么没反应

源 Shader 读取的是抓帧时的光照常量。Unity 的 Directional Light 虽然转了,角色仍然读取旧方向,环境光也没有进入原材质分支。

我把场景主光的方向、线性颜色和强度,以及环境 SH 接到角色常量里。原材质中不同部位有各自的方向偏移,我保留这些偏移,用主光旋转的增量带动它们;全部统一成一个方向会丢掉原来的卡通处理。

默认主光方向查看原图 · 2400 × 1080
接入场景主光后的默认方向。
改变主光方向后的角色查看原图 · 2400 × 1080
同一组截图中转动主光后的结果,脸部、头发和衣服开始响应场景里的 Light。这组图早于最终描边修复。

环境光也保留 Flat、Gradient 和 Skybox 的编辑入口。它们进入原着色方程,之后改材质时才能看清哪一项造成了变化。

07亮度和阴影分开调整

接入场景光以后,角色又比参考亮了一截,脸上尤其明显。往回查有两个原因:默认天空环境增加了原帧没有的贡献,材质之间的光方向偏移也曾被统一掉。

我把默认主光恢复到参考方向,保留材质偏移,环境改为 Flat 黑色。这里的黑色是这个参考场景的起点,环境光参数仍然可以改。

校准前的场景光照查看原图 · 2400 × 1080
校准前:脸和头发的亮度偏高。
校准后的场景光照查看原图 · 2400 × 1080
校准后:恢复参考光照关系,并启用较轻的角色实时受影。两图使用相同捕获姿态和相机,都是描边修复前的结果。

阴影则不能一直使用抓帧里的固定投影。房间原本有两级 CSM,我让它随当前相机、主光和动画模型重新生成,房间与角色都提交新的投影网格。软阴影沿用原来的九点 PCF。

角色原来主要使用自己的接触阴影,没有完整接收房间 CSM。直接加满房间阴影,室内角色就会过黑,所以我把角色受影强度单独留成参数,参考默认使用 0.02。它只影响直接光,环境项和自发光继续走自己的路径。

转动主光后的实时阴影查看原图 · 2400 × 1080
实时 CSM 阶段:改变主光方向后的房间与角色。
实时阴影遮罩查看原图 · 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
原方案:待机 0 秒,原描边、单采样。
投影描边和四倍采样后的完整画面查看原图 · 2400 × 1080
修复版:相同机位、动作时间和光照,启用投影描边与 4× 几何采样。两张保留完整 2400×1080 PNG,点击可查看原尺寸。

页面上方可以拖动比较头部,也可以切到全图。描边目前仍是独立绘制,和本体使用同一份当前蒙皮结果,衣服、包和发辫运动时一起更新。

展开实现 · 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
投影描边与 4× 采样后的同阶段。可以点击原图看发丝、衣领和包的边缘,再与最终画面对照。

这次改善来自几何边缘的覆盖采样。无限大中用于静态预览的 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 调整观察方向,并用场景灯检查角色材质。页面上方的视频展示当前版本;文中的旧截图保留当时的问题,方便看清每一步改了什么。

目前还没恢复原游戏的前帧蒙皮、连续运动矢量、抖动序列和动作相机,部分动画绑定也需要继续整理。头部高光、细节着色和完整后处理仍有差异。后面会先补动态输入,再回到同姿态的参考画面继续调整。