3D 皮肤渲染
账户页右侧那个能转的 Minecraft 角色,是自己写的渲染器画出来的——没有浏览器、没有 WebView、没有帧缓存。
为什么不自己写 PNG 编解码
Starcloud.Core 没有任何 PackageReference(零新增依赖是项目约定),而 .NET BCL 不带 PNG 解码。但 PNG 的 IDAT 就是 zlib 流,BCL 从 .NET 6 起自带 System.IO.Compression.ZLibStream,所以「解压 + 逐行反滤波」就够:
using var inflate = new ZLibStream(input, CompressionMode.Decompress);PngDecoder 只覆盖 Mojang 皮肤/披风的实际需要:bit depth 8;颜色类型 0/2/3/4/6;tRNS(调色板索引 / 灰度与真彩单色键);interlace 必须为 0;多 IDAT 拼接;不校验 CRC(只校验签名与块长度)。多一条都不做。PngEncoder 与它对称,用来把渲染结果导成 PNG。
为什么不是 WebView2
旧实现(Skin3DView)是 WebView2 装 skinview3d,再靠 CapturePreview 抓帧绕开 WebView2 无法透明的限制。绕的方式很难看:抓回来的帧还得缓存、还得找时机合成,浏览器本身也带不走。
D3D11 没有这个问题:直接渲染到离屏纹理,读回字节,写进 Win2D 位图。所以 SkinFigureView 里没有任何浏览器、没有抓帧、没有帧缓存。
两条路径必须像素级一致
SkinRenderer(CPU 逐像素):630×1020 一帧 58ms(约 17fps)SkinGpuRenderer(D3D11):同样尺寸 不到 1ms
软件路径慢到走路动画会发跳,只能靠「降渲染分辨率 + 线性放大」换帧率,代价是像素风糊掉。搬上 GPU 后 1:1 分辨率就够快。但两条路径会同时存在——没有可用显卡时回退软件光栅化——所以它们必须给出同一张图。
为此,投影只有一份实现。 SkinProjection 被两边共用:
public readonly record struct ScreenVertex(double Sx, double Sy, double Z, double U, double V);
public static List<ScreenQuad> Project(
IReadOnlyList<Quad> quads, Camera camera, int width, int height, double centerY)软件路径把 ScreenVertex 转成带 1/z 预乘的顶点自己做重心坐标;GPU 路径把它直接写进顶点缓冲,透视校正交给硬件光栅器。这段数学原先埋在软件光栅化器里,加 GPU 路径时若照抄一份,两条路就会慢慢跑偏。排序也是共享的:OrderBy(q => q.ViewDepth) 是稳定排序,深度相同的面片保持模型顺序,CPU / GPU 的「谁先占位」才完全一致。
GPU 路径怎么做到等价
一、透视校正靠除 1/z 复现。 顶点里带的是 uv/z 与 1/z,像素着色器里再除回来:
float2 uv = input.Uvz.xy / input.Uvz.z;原理是:顶点着色器输出 w = 1,硬件光栅器就按屏幕空间线性插值属性;而软件光栅器算的是 u = (w0*uz + w1*uz + w2*uz) / (w0*iz + w1*iz + w2*iz)。除掉 1/z 之后两者等价。
二、Texture2D.Load 取整数纹素。 不引采样器,直接 Skin.Load(int3(texel, 0)),和 CPU 的 (int)u 取数完全对齐——Minecraft 本来就是像素风,插值反而毁质感。
三、纹素坐标按面片 UV 矩形钳制。 这一条是「边缘杂色描边」bug 的根因:
texel = clamp(texel, (int2)input.Rect.xy, (int2)input.Rect.zw);轮廓边缘的像素是被「保守填充」进三角形的外半像素,插值出的 UV 会越过面片边界,直接采样就会读到图集里相邻部件的纹素——表现就是模型边缘一条杂色的 1px 描边;读到 alpha=0 的纹素时轮廓还会变成虚线。CPU 路径做的是同一件事(if (su < uMin) su = uMin; else if (su > uMax) su = uMax;)。
GPU 侧也不做背面剔除(RasterizerDescription.CullNone)、深度用严格 Less——都与 CPU 的「靠深度解决内外层、iz <= depth 则跳过」一致。
HLSL 必须是纯 ASCII
Vortice 把着色器源码按 ANSI 交给 D3DCompile,中文注释会被截断成语法错误,实测报 X3000: unexpected end of file。C# 源码照旧用中文注释,但 ShaderSource 这个字符串里一个字都不能有中文。
读回与资源生命周期
GPU 渲染到离屏纹理 → CopyResource 到 staging → Map 回读到 byte[]。回读 2.6MB 约 1ms,换来的是「不跨设备共享纹理、不碰 Win2D 设备、不引入线程时序问题」的干净边界。读回时行距必须按驱动给的 mapped.RowPitch 走,不能假设等于 width * 4。
Render(后台线程)与 Dispose(UI 线程,视图卸载时)必须互斥(private readonly object _gate = new();)。没有它会真的崩——卸载时 Dispose 把顶点缓冲等 COM 对象放掉,正在跑的 UpdateSubresource 就握着野指针 → AccessViolationException。这不是能 catch 的异常,整个进程直接没(实测在进入账户详情页时崩过一次,WER 报 coreclr + 0xc0000005)。有锁之后最坏情况是 Render 拿到 ObjectDisposedException,被调用方 catch 住回退软件光栅化。
自适应分辨率下限是 0.85(MinAdaptiveScale),只在「明显跟不上」(一帧超过 60ms)时才降——宁可帧率低一点也要保住像素风的锐利。走 GPU 时固定 1:1。