研究 CCanvas 类。抗锯齿和阴影·进阶篇
🎨

研究 CCanvas 类。抗锯齿和阴影·进阶篇

(2/3)·坐标分数像素如何被拆成多饱和度着色,直接决定指标线的观感与阴影真实度

实战向进阶 第 2/3 篇
很多人在 CCanvas 里画线总觉得边缘发毛,其实是把 PixelSetAA 的坐标当 int 硬塞。带分数的点若不加区分,渲染出来要么丢像素要么锯齿拉满。阴影画得像贴纸,也常因没走正态分布权重。

◍ 抗锯齿绘制里的像素权重分配

在 MT5 自定义指标里做亚像素级画线,第一步是判断是否需要抗锯齿。当 dx 和 dy 同时等于 0.0 时,落点正好是整数像素,直接 PixelSet 写色并返回,省掉后续四个邻域的计算开销。 若落点带小数偏移,代码会先准备 4 个相邻像素的坐标数组 xx、yy。依据 dx、dy 的正负零三种情况,分别把邻域锁在 ix-1、ix、ix+1 与 iy-1、iy、iy+1,构成以目标点为中心的 2×2 网格。 权重来自距离平方的倒数。循环里 rr[i]=1/(dx*dx+dy*dy),再累加进 rrr;距离越近的像素分到的 k=rr[i]/rrr 越大。实测当偏移量约 0.5 像素时,最近邻与对角邻的权重比可能接近 3:1,混色后边缘过渡更顺。 最后按 k 做源色与目标色各通道的线性混合:a/r/g/b 四个 uchar 分别用 k*GETRGBx(clr)+(1-k)*GETRGBx(c) 算出来,再 ARGB 写回。外汇与贵金属图表高频重绘,这种逐像素操作需注意 CPU 占用,属于高风险环境下的性能敏感代码。

MQL5 / C++
class="type">uchar  a,r,g,b;
class="type">uint   c;
class=class="str">"cmt">//--- 对于抗锯齿没必要
if(dx==class="num">0.0 && dy==class="num">0.0)
  {
   PixelSet(ix,iy,clr);
   class="kw">return;
  }
PixelSet(ix,iy,clr);
   class="kw">return;
   }
class=class="str">"cmt">//--- 准备像素数组
xx[class="num">0]=xx[class="num">2]=ix;
yy[class="num">0]=yy[class="num">1]=iy;
if(dx<class="num">0.0)
    xx[class="num">1]=xx[class="num">3]=ix-class="num">1;
if(dx==class="num">0.0)
    xx[class="num">1]=xx[class="num">3]=ix;
if(dx>class="num">0.0)
    xx[class="num">1]=xx[class="num">3]=ix+class="num">1;
if(dy<class="num">0.0)
    yy[class="num">2]=yy[class="num">2]=iy-class="num">1;
if(dy==class="num">0.0)
    yy[class="num">2]=yy[class="num">2]=iy;
if(dy>class="num">0.0)
    yy[class="num">2]=yy[class="num">2]=iy+class="num">1;
yy[class="num">2]=yy[class="num">2]=iy+class="num">1;
class=class="str">"cmt">//--- 计算半径及其平方和
for(class="type">int i=class="num">0;i<class="num">4;i++)
  {
   dx=xx[i]-x;
   dy=yy[i]-y;
   rr[i]=class="num">1/(dx*dx+dy*dy);
   rrr+=rr[i];
  }
rrr+=rr[i];
   }
class=class="str">"cmt">//--- 绘制像素
for(class="type">int i=class="num">0;i<class="num">4;i++)
  {
   k=rr[i]/rrr;
   c=PixelGet(xx[i],yy[i]);
   a=(class="type">uchar)(k*GETRGBA(clr)+(class="num">1-k)*GETRGBA(c));
   r=(class="type">uchar)(k*GETRGBR(clr)+(class="num">1-k)*GETRGBR(c));
   g=(class="type">uchar)(k*GETRGBG(clr)+(class="num">1-k)*GETRGBG(c));
   b=(class="type">uchar)(k*GETRGBB(clr)+(class="num">1-k)*GETRGBB(c));
   PixelSet(xx[i],yy[i],ARGB(a,r,g,b));
  }

给图形对象加阴影背后的像素算法

给 MT5 图表上的图形对象绘上阴影,不只是为了视觉上脱离「平片」感。阴影本身通常是透明的,把带阴影的图形叠在另一张带阴影的图形上,能堆出层次和立体错觉,这一点在自绘指标面板里很实用。 阴影主要分「光环」和「外部对角线」两类。光环可设光环宽度,外部对角线可设透视角度,两者都能指定颜色。放大看,「光环」其实由多条 1 像素宽的轮廓组成,色彩饱和度逐层递减,并非一笔涂实。 要做出平稳过渡的阴影,核心是用高斯模糊。算法对每个像素套用正态分布,模糊半径 Radius 决定取周边 N×N 像素网格参与计算;Radius=3 时就是 7×7 的网格。利用可分离性,先沿 X 轴模糊再沿 Y 轴处理,速度更快且画质不损。 权重系数可用 ALGLIB 的 GQGenerateRec 生成。脚本 GQGenerateRecToExel.mq5 会把数组写到 <data catalogue>\MQL5\Files\GQGenerateRecToExel.csv,从导出的图能直接看到中间像素权重高、越远越低的钟形分布。 读取阴影资源时走 ResourceReadImage,像素按 uint(ARGB)存。二维图转一维数组的规则是逐行首尾相接:4×3 的图像先搬完第 1 行 4 个像素,再第 2 行、第 3 行。写自定义画布前先吃透这个排布,否则取色会错位。 下面这段是获取正态分布权重的核心函数,调用失败会返回 false,成功则把系数填进 w[]。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| 获取积分权重数组                                                        |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">bool GetQuadratureWeights(class="kw">const class="type">class="kw">double mu0,class="kw">const class="type">int n,class="type">class="kw">double &w[])
  {
   CAlglib alglib;                   class=class="str">"cmt">// 类 CAlglib 的静态成员
   class="type">class="kw">double       alp[];               class=class="str">"cmt">// α 系数数组 
   class="type">class="kw">double       bet[];               class=class="str">"cmt">// β 系数数组
   ArrayResize(alp,n);
   ArrayResize(bet,n);
   ArrayInitialize(alp,class="num">1.0);         class=class="str">"cmt">// 初始化 α 数组数值
   ArrayInitialize(bet,class="num">1.0);         class=class="str">"cmt">// 初始化 β 数组数值
   class="type">class="kw">double       out_x[];
   class="type">int          inf=class="num">0;
class=class="str">"cmt">//| 错误代码:                                                              |
class=class="str">"cmt">//|        * -class="num">3   内部特征问题求解器尚未                                   |
class=class="str">"cmt">//|               融合                                                     |
class=class="str">"cmt">//|        * -class="num">2   Beta[i]<=class="num">0                                               |
class=class="str">"cmt">//|        * -class="num">1   传递的 N 不正确                                          |
class=class="str">"cmt">//|         *  class="num">1   OK                                                      |
   alglib.GQGenerateRec(alp,bet,mu0,n,inf,out_x,w);
   if(inf!=class="num">1)
     {
      Print("调用 CGaussQ::GQGenerateRec 错误");
      class="kw">return(class="kw">false);
     }
   class="kw">return(true);
  }

「用两层画布给图形加高斯模糊阴影」

在 MT5 里做图表叠加特效,最干净的做法是开两块 CCanvas:一块专门画阴影底层,一块当工作层画实体图形。两层尺寸都取图表宽高,底层先 FillRectangle 涂半透明黑,上层错开 ShadowShift 像素画蓝矩形,未模糊前能直接看到硬边阴影。 模糊只动底层画布。先把 CanvasShadow 的资源图通过 ResourceReadImage 读进一维数组,再用 GetQuadratureWeights 拿到正态分布权重,把像素拆成 Alpha、Red、Green、Blue 四个分量分别处理——因为高斯核必须作用在每个色彩通道上才不出偏色。 算法利用高斯滤波器可分离性:先沿 X 轴套两层嵌套循环扫每个像素,左右各取 radius 个邻点,邻点数加中心像素正好等于权重数组长度;每个邻点乘对应权重后累加,就得到该通道模糊值。示例里 radius=4,红通道计算时左右各 4 个像素参与卷积。 边缘会残留未模糊条纹,宽度等于模糊半径,半径越大条带越宽,代码里靠复制边界像素消除这个工件。Y 轴重复同样操作,最后把四个模糊分量合回 ARGB 写进 CanvasShadow,阴影就变柔了。外汇与贵金属图表叠加此类视觉辅助时需注意,过度绘制可能增加终端渲染负担,且任何图形修饰都不改变品种本身的高风险属性。

MQL5 / C++
class="macro">#class="kw">property script_show_inputs
class="macro">#include <Canvas\Canvas.mqh>
class="macro">#include <Math\Alglib\alglib.mqh>
class=class="str">"cmt">//--- 输入
input class="type">uint   radius=class="num">4;                class=class="str">"cmt">// 模糊半径
input class="type">class="kw">color clrShadow=clrBlack;       class=class="str">"cmt">// 阴影颜色
input class="type">uchar ShadowTransparence=class="num">160;   class=class="str">"cmt">// 透明度
input class="type">int   ShadowShift=class="num">3;            class=class="str">"cmt">// 阴影偏移
input class="type">class="kw">color clrDraw=clrBlue;          class=class="str">"cmt">// 阴影颜色
input class="type">uchar DrawwTransparence=class="num">255;    class=class="str">"cmt">// 透明度
class=class="str">"cmt">//---
class=class="str">"cmt">//--- 创建画布
  CCanvas CanvasShadow;
  CCanvas CanvasDraw;
  if(!CanvasShadow.CreateBitmapLabel("ShadowLayer",class="num">0,class="num">0,ChartWidth,
     ChartHeight,COLOR_FORMAT_ARGB_NORMALIZE))
   {
     Print("创建画布错误: ",GetLastError());
     class="kw">return;
   }
  if(!CanvasDraw.CreateBitmapLabel("DrawLayer",class="num">0,class="num">0,ChartWidth
     ,ChartHeight,COLOR_FORMAT_ARGB_NORMALIZE))
   {
     Print("创建画布错误: ",GetLastError());
     class="kw">return;
   }
class=class="str">"cmt">//--- 在画布上绘图
  CanvasShadow.Erase(ColorToARGB(clrNONE,class="num">0));
  CanvasShadow.FillRectangle(ChartWidth/class="num">10,ChartHeight/class="num">10,
             ChartWidth/class="num">2-ChartWidth/class="num">10,ChartHeight/class="num">10*class="num">9,ColorToARGB(clrShadow,ShadowTransparence));
  CanvasShadow.FillRectangle(ChartWidth/class="num">2,ChartHeight/class="num">12,ChartWidth/class="num">3*class="num">2,
             ChartHeight/class="num">2,ColorToARGB(clrShadow,ShadowTransparence));
  CanvasShadow.Update();
  CanvasDraw.Erase(ColorToARGB(clrNONE,class="num">0));
  CanvasDraw.FillRectangle(ChartWidth/class="num">10-ShadowShift,ChartHeight/class="num">10-ShadowShift,ChartWidth/class="num">2-ChartWidth/class="num">10-ShadowShift,
             ChartHeight/class="num">10*class="num">9-ShadowShift,ColorToARGB(clrDraw,DrawwTransparence));
  CanvasDraw.Update();

◍ 把资源图拆成 RGBA 再做水平模糊

在 MT5 里用 Canvas 画完阴影后,第一步不是直接贴图,而是把图形资源读回内存数组。ResourceReadImage 第一次调用可能因资源名未就绪而失败,代码里做了二次重试:若第二次仍返回 false,才 Print 错误并 return,这种容错在外接 bmp/png 资源时很实用。 读图成功后,用 GETRGBA/R/G/B 宏把每个像素拆成四个分量存进独立数组。假设图宽 res_width=512、高 res_height=512,size 就是 262144,循环一次就把 26 万像素全部分解,后续滤波只动数组不再碰资源,速度明显优于实时取色。 水平模糊只沿 X 轴卷绕:以 radius 为半径,对 XY=Y*res_width+X 处的左右各 j 个像素按 weights 加权求和。weights 来自 GetQuadratureWeights(1,NNodes,weights),若半径设 3,则 i 从 -3 到 3 共 7 个采样点,coef 同步累加索引,最后 MathRound 写回 uchar 数组。 缓冲区先清零再累加能避免上一帧残影;若你发现阴影边缘有拖尾,优先查 radius 与 weights 节点数是否匹配,而不是怀疑 Canvas 渲染层。

MQL5 / C++
  ResetLastError();
  if(!ResourceReadImage(CanvasShadow.ResourceName(),res_data,res_width,res_height))
    {
     Print("从图形资源里读取数据错误 ",GetLastError());
     Print("尝试第二次");
     class=class="str">"cmt">//--- 尝试第二次: 现在图片宽度和长度已知
     ResetLastError();
     if(!ResourceReadImage(CanvasShadow.ResourceName(),res_data,res_width,res_height))
       {
        Print("从图形资源里读取数据错误 ",GetLastError());
        class="kw">return;
       }
    }
...
  if(!GetQuadratureWeights(class="num">1,NNodes,weights))
     class="kw">return;
  for(class="type">int i=class="num">0;i<size;i++)
    {
     clr_temp=res_data[i];
     a_data[i]=GETRGBA(clr_temp);
     r_data[i]=GETRGBR(clr_temp);
     g_data[i]=GETRGBG(clr_temp);
     b_data[i]=GETRGBB(clr_temp);
    }
  class="type">uint XY;             class=class="str">"cmt">// 数组中的像素坐标
  class="type">class="kw">double  a_temp=class="num">0.0,r_temp=class="num">0.0,g_temp=class="num">0.0,b_temp=class="num">0.0;
  class="type">int     coef=class="num">0;
  class="type">int     j=(class="type">int)radius;
  for(class="type">uint Y=class="num">0;Y<res_height;Y++)               class=class="str">"cmt">// 圈定图像宽度
    {
     for(class="type">uint X=radius;X<res_width-radius;X++) class=class="str">"cmt">// 圈定图像高度
       {
        XY=Y*res_width+X;
        a_temp=class="num">0.0; r_temp=class="num">0.0; g_temp=class="num">0.0; b_temp=class="num">0.0;
        coef=class="num">0;
        for(class="type">int i=-class="num">1*j;i<j+class="num">1;i=i+class="num">1)
          {
           a_temp+=a_data[XY+i]*weights[coef];
           r_temp+=r_data[XY+i]*weights[coef];
           g_temp+=g_data[XY+i]*weights[coef];
           b_temp+=b_data[XY+i]*weights[coef];
           coef++;
          }
        a_data[XY]=(class="type">uchar)MathRound(a_temp);
        r_data[XY]=(class="type">uchar)MathRound(r_temp);

边缘工件清理与横向卷积收口

做完纵向平滑后,图像左右两侧会留下宽度等于 radius 的未处理条带,直接拿来画会导致边框颜色突变。代码用两个 for 循环把左侧 x∈[0,radius) 的像素强行赋值为同排第 radius 个像素的 RGBA 值,右侧 x∈[res_width-radius,res_width) 则复制下一排倒数第 radius 个像素,相当于把有效区边缘向外平推一格。 横向卷积段和纵向几乎对称:只对 X 从 radius 到 res_width-radius 的 interior 像素做加权求和,权重数组 weights 按 coef 顺序取用,j 控制半窗大小,i 以步长 1 从 -j 走到 j。四个通道各自累加完再 MathRound 转回 uchar,避免浮点残留。 把这套两段式(先竖后横)滤波接在前面生成的离屏缓冲后面,MT5 里把 radius 设 2~3、weights 用 [1,2,1] 之类简单核,就能看到图表缩略图噪点明显减少。外汇与贵金属行情图高频刷新,这种预处理只影响缩略渲染、不改动 tick 数据,实盘使用前建议在策略测试器里跑一遍确认无数组越界。

MQL5 / C++
g_data[XY]=(class="type">uchar)MathRound(g_temp);
b_data[XY]=(class="type">uchar)MathRound(b_temp);
}
class=class="str">"cmt">//--- 删除左侧工件
for(class="type">uint x=class="num">0;x<radius;x++)
  {
   XY=Y*res_width+x;
   a_data[XY]=a_data[Y*res_width+radius];
   r_data[XY]=r_data[Y*res_width+radius];
   g_data[XY]=g_data[Y*res_width+radius];
   b_data[XY]=b_data[Y*res_width+radius];
  }
class=class="str">"cmt">//--- 删除右侧工件
for(class="type">uint x=res_width-radius;x<res_width;x++)
  {
   XY=Y*res_width+x;
   a_data[XY]=a_data[(Y+class="num">1)*res_width-radius-class="num">1];
   r_data[XY]=r_data[(Y+class="num">1)*res_width-radius-class="num">1];
   g_data[XY]=g_data[(Y+class="num">1)*res_width-radius-class="num">1];
   b_data[XY]=b_data[(Y+class="num">1)*res_width-radius-class="num">1];
  }
 }
for(class="type">uint Y=class="num">0;Y<res_height;Y++)                    class=class="str">"cmt">// 圈定图像宽度
  {
   for(class="type">uint X=radius;X<res_width-radius;X++)      class=class="str">"cmt">// 圈定图像高度
     {
      ...
     }
  }
for(class="type">uint X=radius;X<res_width-radius;X++)         class=class="str">"cmt">// 圈定图像高度
      {
       XY=Y*res_width+X;
       a_temp=class="num">0.0; r_temp=class="num">0.0; g_temp=class="num">0.0; b_temp=class="num">0.0;
       coef=class="num">0;
       for(class="type">int i=-class="num">1*j;i<j+class="num">1;i=i+class="num">1)
         {
          a_temp+=a_data[XY+i]*weights[coef];
          r_temp+=r_data[XY+i]*weights[coef];
          g_temp+=g_data[XY+i]*weights[coef];
          b_temp+=b_data[XY+i]*weights[coef];
          coef++;
         }
       a_data[XY]=(class="type">uchar)MathRound(a_temp);
       r_data[XY]=(class="type">uchar)MathRound(r_temp);
       g_data[XY]=(class="type">uchar)MathRound(g_temp);
       b_data[XY]=(class="type">uchar)MathRound(b_temp);
      }
class=class="str">"cmt">//--- 删除左侧工件
      for(class="type">uint x=class="num">0;x<radius;x++)
        {
         XY=Y*res_width+x;
         a_data[XY]=a_data[Y*res_width+radius];
         r_data[XY]=r_data[Y*res_width+radius];
         g_data[XY]=g_data[Y*res_width+radius];
         b_data[XY]=b_data[Y*res_width+radius];
        }
      class=class="str">"cmt">//--- 删除右侧工件
      for(class="type">uint x=res_width-radius;x<res_width;x++)
        {
         XY=Y*res_width+x;
         a_data[XY]=a_data[(Y+class="num">1)*res_width-radius-class="num">1];
把阴影权重测算交给小布
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到画布抗锯齿与阴影参数的实时反馈,你专注调策略逻辑就好。

常见问题

PixelSetAA 坐标须用 double,CircleAA 坐标用 int 但圆形尺寸用 double;弄混会导致编译异常或图形偏移。
可以,小布盯盘内置了画布诊断模块,能对照坐标类型与阴影算法给出提示,省去手动比对文档。
高斯模糊涉及卷积核遍历,资源占用倾向更高;简单偏移阴影仅绘制副本,适合低频刷新场景。
算法按邻近像素距离分配颜色权重,双分数坐标落入四个像素交汇区,故用三像素插值实现平滑。
它决定模糊核各位置权重,让阴影过渡符合视觉衰减,避免生硬边界;详见本篇 3.2 节。