GameTech/URP 渲染实战

案例 4.6Voronoi 细胞进阶

目标:生成 Voronoi(又称 Worley、细胞噪声)图案,分别展示“到最近点的距离”“细胞边界”和“按细胞上色的鹅卵石”。它是石板路、龟裂土地、细胞、鳞片、第 18 章护盾、第 19 章水下焦散的基础。

pF1F2● 每格一个随机特征点(哈希决定位置)蓝框:p 所在格及 8 个邻居 = 搜索范围F1:到最近特征点的距离F2:到次近特征点的距离色块:离哪个特征点最近 = 一个细胞F2 − F1 ≈ 0 的地方就是细胞边界
图 4-11Voronoi 的构造(细胞边界按真实几何绘制)。每个虚线格子里有一个随机特征点;查询点 p 只需在蓝框的 3×3 邻域里寻找最近点(F1)与次近点(F2)

做法是 4.3 节“编号 + 局部坐标”结构的延伸:每个格子用哈希决定一个特征点的位置,平面上每个点都属于离它最近的那个特征点,形成一个个凸多边形细胞。由于特征点留在自己的格子里,最近的点一定在当前格及其邻居之中,不需要遍历所有点。

Assets/Cookbook/Ch04/ProceduralLib.hlsl(节选:voronoi)
// Worley / Voronoi:每格放一个随机特征点,求到最近(F1)和次近(F2)特征点的距离
// phase 让特征点在格子内绕圈移动,用来做动画
float2 VoronoiPoint(float2 cell, float phase)
{
    float2 h = Hash22(cell);
    return 0.5 + 0.4 * sin(phase + TWO_PI * h);   // 特征点在格内的位置,始终留在 [0.1,0.9]
}

void Voronoi(float2 p, float phase, out float f1, out float f2, out float2 nearestCell)
{
    float2 i = floor(p);
    float2 f = frac(p);
    f1 = 8.0; f2 = 8.0; nearestCell = i;
    // F1 用 3×3 邻域就够;F2(次近点)可能在更远的格子里,所以搜 5×5
    [unroll] for (int y = -2; y <= 2; y++)
    [unroll] for (int x = -2; x <= 2; x++)
    {
        float2 g = float2(x, y);                       // 邻居格子相对偏移
        float d = length(g + VoronoiPoint(i + g, phase) - f);
        if (d < f1)      { f2 = f1; f1 = d; nearestCell = i + g; }
        else if (d < f2) { f2 = d; }
    }
}

// 到细胞边界的“真实”距离:边界是两个特征点连线的中垂线,
// 点到中垂线的距离 = dot(两点中点 - 当前点, 两点连线方向)
float VoronoiBorder(float2 p, float phase)
{
    float2 i = floor(p);
    float2 f = frac(p);
    // 第一遍:找最近点(相对当前点的向量 mr)
    float2 mg = 0, mr = 0; float md = 8.0;
    [unroll] for (int y = -1; y <= 1; y++)
    [unroll] for (int x = -1; x <= 1; x++)
    {
        float2 g = float2(x, y);
        float2 r = g + VoronoiPoint(i + g, phase) - f;
        float d = dot(r, r);
        if (d < md) { md = d; mr = r; mg = g; }
    }
    // 第二遍:以最近点所在格为中心,对其余每个点求到中垂线的距离,取最小
    md = 8.0;
    [unroll] for (int y2 = -2; y2 <= 2; y2++)
    [unroll] for (int x2 = -2; x2 <= 2; x2++)
    {
        float2 g = mg + float2(x2, y2);
        float2 r = g + VoronoiPoint(i + g, phase) - f;
        float2 diff = r - mr;
        if (dot(diff, diff) > 1e-5)
            md = min(md, dot(0.5 * (mr + r), normalize(diff)));
    }
    return md;
}
// Worley / Voronoi:每格放一个随机特征点,求到最近(F1)和次近(F2)特征点的距离
// phase 让特征点在格子内绕圈移动,用来做动画
vec2 VoronoiPoint(vec2 cell, float phase)
{
    vec2 h = Hash22(cell);
    return 0.5 + 0.4 * sin(phase + TWO_PI * h);   // 特征点在格内的位置,始终留在 [0.1,0.9]
}

void Voronoi(vec2 p, float phase, out float f1, out float f2, out vec2 nearestCell)
{
    vec2 i = floor(p);
    vec2 f = fract(p);
    f1 = 8.0; f2 = 8.0; nearestCell = i;
    // F1 用 3×3 邻域就够;F2(次近点)可能在更远的格子里,所以搜 5×5
    for (int y = -2; y <= 2; y++)
    for (int x = -2; x <= 2; x++)
    {
        vec2 g = vec2(x, y);                           // 邻居格子相对偏移
        float d = length(g + VoronoiPoint(i + g, phase) - f);
        if (d < f1)      { f2 = f1; f1 = d; nearestCell = i + g; }
        else if (d < f2) { f2 = d; }
    }
}

// 到细胞边界的“真实”距离:边界是两个特征点连线的中垂线,
// 点到中垂线的距离 = dot(两点中点 - 当前点, 两点连线方向)
float VoronoiBorder(vec2 p, float phase)
{
    vec2 i = floor(p);
    vec2 f = fract(p);
    // 第一遍:找最近点(相对当前点的向量 mr)
    vec2 mg = vec2(0.0), mr = vec2(0.0); float md = 8.0;
    for (int y = -1; y <= 1; y++)
    for (int x = -1; x <= 1; x++)
    {
        vec2 g = vec2(x, y);
        vec2 r = g + VoronoiPoint(i + g, phase) - f;
        float d = dot(r, r);
        if (d < md) { md = d; mr = r; mg = g; }
    }
    // 第二遍:以最近点所在格为中心,对其余每个点求到中垂线的距离,取最小
    md = 8.0;
    for (int y2 = -2; y2 <= 2; y2++)
    for (int x2 = -2; x2 <= 2; x2++)
    {
        vec2 g = mg + vec2(x2, y2);
        vec2 r = g + VoronoiPoint(i + g, phase) - f;
        vec2 diff = r - mr;
        if (dot(diff, diff) > 1e-5)
            md = min(md, dot(0.5 * (mr + r), normalize(diff)));
    }
    return md;
}

函数里有三个值得注意的地方:

Assets/Cookbook/Ch04/C04_06_Voronoi.shader(节选:shade)
half3 col;
if (_Mode < 0.5)
{
    // F1:到最近特征点的距离 → 一个个“圆锥”,加等高线更容易看出形状
    col = lerp(_ColorA.rgb, _ColorB.rgb, saturate(f1 * 1.4));
    col *= 0.85 + 0.15 * step(0.5, frac(f1 * 8.0));
    col = lerp(col, _EdgeColor.rgb, 1.0 - smoothstep(0.03, 0.05, f1));   // 特征点
}
else if (_Mode < 1.5)
{
    // F2 - F1:两个最近点距离相等的地方就是细胞边界,那里差值趋于 0
    float e = f2 - f1;
    col = lerp(_EdgeColor.rgb, _ColorB.rgb, smoothstep(0.0, _EdgeWidth * 2.0, e));
}
else
{
    // 用最近格子的编号再哈希一次,每个细胞一个固定颜色
    float h = Hash21(cell + 31.7);
    col = lerp(_ColorA.rgb, _ColorB.rgb, h);
    // 用到边界的真实距离做明暗和描边:中心亮、边缘暗,像鹅卵石
    float border = VoronoiBorder(p, _Phase + _Time.y * _Speed);
    col *= lerp(0.7, 1.1, saturate(border * 2.5));
    float aa = fwidth(border);
    col = lerp(_EdgeColor.rgb, col, smoothstep(_EdgeWidth * 0.5 - aa, _EdgeWidth * 0.5 + aa, border));
}
vec3 col;
if (_Mode < 0.5)
{
    // F1:到最近特征点的距离 → 一个个“圆锥”,加等高线更容易看出形状
    col = mix(_ColorA.rgb, _ColorB.rgb, saturate(f1 * 1.4));
    col *= 0.85 + 0.15 * step(0.5, fract(f1 * 8.0));
    col = mix(col, _EdgeColor.rgb, 1.0 - smoothstep(0.03, 0.05, f1));   // 特征点
}
else if (_Mode < 1.5)
{
    // F2 - F1:两个最近点距离相等的地方就是细胞边界,那里差值趋于 0
    float e = f2 - f1;
    col = mix(_EdgeColor.rgb, _ColorB.rgb, smoothstep(0.0, _EdgeWidth * 2.0, e));
}
else
{
    // 用最近格子的编号再哈希一次,每个细胞一个固定颜色
    float h = Hash21(cell + 31.7);
    col = mix(_ColorA.rgb, _ColorB.rgb, h);
    // 用到边界的真实距离做明暗和描边:中心亮、边缘暗,像鹅卵石
    float border = VoronoiBorder(p, _Phase + _Time.y * _Speed);
    col *= mix(0.7, 1.1, saturate(border * 2.5));
    float aa = fwidth(border);
    col = mix(_EdgeColor.rgb, col, smoothstep(_EdgeWidth * 0.5 - aa, _EdgeWidth * 0.5 + aa, border));
}
图 4-12左:F1 距离(加了等高线,黑点是特征点);中:用 F2 − F1 画边界,注意右上方和中部有几处两个特征点挨得很近,边界糊成了一片;右:用精确的边界距离描边,并按细胞编号哈希上色、按边界距离做中心亮边缘暗,边宽处处一致
性能图 4-12 右图的着色每像素要调用一次 Voronoi(25 次哈希)和一次 VoronoiBorder(9 + 25 次),合计约 60 次哈希与距离计算,在移动端整屏使用时开销可观。静态的 Voronoi 图案最好烘焙成贴图;需要动画时,考虑降低分辨率渲染到 RenderTexture,或只在必要的区域(比如护盾的命中点附近)计算。