GameTech/URP 渲染实战

案例 4.1在 Shader 里画函数曲线入门

目标:写一个“示波器” Shader,把任意函数 y = f(x) 画成带网格和坐标轴的曲线。调参数学函数时,这比在脑子里想象可靠得多;它同时也是“用距离 + fwidth 画抗锯齿线条”的第一个练习。

Assets/Cookbook/Ch04/C04_01_FunctionPlot.shader(节选:curves)
// 第 k 条曲线 y = f(x)。改这里就能画任意函数
float Curve(int mode, int k, float x)
{
    if (mode == 0)          // 阈值类:step / smoothstep / 线性 step
    {
        if (k == 0) return step(0.5, x);
        if (k == 1) return smoothstep(0.2, 0.8, x);
        return LinearStep(0.2, 0.8, x);
    }
    if (mode == 1)          // 重复类:floor 阶梯 / frac 锯齿 / 三角波
    {
        if (k == 0) return floor(x * 4.0) / 4.0;
        if (k == 1) return frac(x * 2.0);
        return abs(frac(x) - 0.5) * 2.0;
    }
    // 形状类:脉冲(两个 smoothstep 相减)/ 正弦重映射 / 幂函数
    if (k == 0) return smoothstep(0.25, 0.35, x) - smoothstep(0.65, 0.75, x);
    if (k == 1) return 0.5 + 0.5 * sin(x * TWO_PI * 2.0);
    return x * x * x;
}
// 第 k 条曲线 y = f(x)。改这里就能画任意函数
float Curve(int mode, int k, float x)
{
    if (mode == 0)          // 阈值类:step / smoothstep / 线性 step
    {
        if (k == 0) return step(0.5, x);
        if (k == 1) return smoothstep(0.2, 0.8, x);
        return LinearStep(0.2, 0.8, x);
    }
    if (mode == 1)          // 重复类:floor 阶梯 / frac 锯齿 / 三角波
    {
        if (k == 0) return floor(x * 4.0) / 4.0;
        if (k == 1) return fract(x * 2.0);
        return abs(fract(x) - 0.5) * 2.0;
    }
    // 形状类:脉冲(两个 smoothstep 相减)/ 正弦重映射 / 幂函数
    if (k == 0) return smoothstep(0.25, 0.35, x) - smoothstep(0.65, 0.75, x);
    if (k == 1) return 0.5 + 0.5 * sin(x * TWO_PI * 2.0);
    return x * x * x;
}

想画自己的函数,只要改 Curve。真正的技巧在“怎么把曲线画成等宽的线”:

Assets/Cookbook/Ch04/C04_01_FunctionPlot.shader(节选:plot)
// 抗锯齿画线:把曲线在当前像素附近近似成一小段折线(左右各一个像素),
// 求到这段线段的距离,再换算成“像素单位”。跳变处线段几乎竖直,竖线也能自然连上
float PlotCurve(int mode, int k, float2 q, float pixel)
{
    float2 a = float2(q.x - pixel, Curve(mode, k, q.x - pixel));
    float2 b = float2(q.x + pixel, Curve(mode, k, q.x + pixel));
    float dist = SdSegment(q, a, b);
    return saturate(_LineWidth * 0.5 + 0.5 - dist / pixel);
}

// 到 d=0 处一条宽 widthPx 像素的线
float LineAt(float d, float pixel, float widthPx)
{
    return saturate(widthPx * 0.5 + 0.5 - abs(d) / pixel);
}

// 每隔 spacing 一条网格线:frac 把坐标折回到最近网格线附近
float GridLine(float v, float spacing, float pixel, float widthPx)
{
    float d = (frac(v / spacing + 0.5) - 0.5) * spacing;
    return LineAt(d, pixel, widthPx);
}
// 抗锯齿画线:把曲线在当前像素附近近似成一小段折线(左右各一个像素),
// 求到这段线段的距离,再换算成“像素单位”。跳变处线段几乎竖直,竖线也能自然连上
float PlotCurve(int mode, int k, vec2 q, float pixel)
{
    vec2 a = vec2(q.x - pixel, Curve(mode, k, q.x - pixel));
    vec2 b = vec2(q.x + pixel, Curve(mode, k, q.x + pixel));
    float dist = SdSegment(q, a, b);
    return saturate(_LineWidth * 0.5 + 0.5 - dist / pixel);
}

// 到 d=0 处一条宽 widthPx 像素的线
float LineAt(float d, float pixel, float widthPx)
{
    return saturate(widthPx * 0.5 + 0.5 - abs(d) / pixel);
}

// 每隔 spacing 一条网格线:fract 把坐标折回到最近网格线附近
float GridLine(float v, float spacing, float pixel, float widthPx)
{
    float d = (fract(v / spacing + 0.5) - 0.5) * spacing;
    return LineAt(d, pixel, widthPx);
}

最朴素的写法是 abs(q.y − f(q.x)) < 线宽,但它测的是竖直距离:曲线陡的地方,竖直距离变化很快,线就变细;在 step 的跳变处,竖线干脆画不出来。这里改为在当前像素左右各一个像素的位置取两个点,把曲线局部近似成一小段线段,用 SdSegment(4.6 节)求垂直于曲线的真实距离,再除以一个像素的大小 pixel = fwidth(q.x),得到“离曲线几个像素”。这样任何斜率下线宽都一致,跳变处的竖线也自然连上了。

图 4-4Shader 画出的曲线(x、y 范围都是 −0.2~1.2,网格间隔 0.25)。左:step(0.5,x) 橙、smoothstep(0.2,0.8,x) 蓝、线性 step 绿,可以看到 smoothstep 两端更“软”;中:floor(4x)/4 阶梯、frac(2x) 锯齿、三角波;右:smoothstep 脉冲、0.5+0.5sin(4πx)、x³