13

【玩转Arm-2D】如何制作具有物理质感的仪表指针

image.png

【说在前面的话】

说起来挺“幽默”的:当大家“吭哧吭哧”给各类仪表完成从模拟、机械到数字化的转变后,不满于断码显示的“土气”,客户开始喜欢那些在340 240甚至是240 240的小彩屏上追求拥有“复古拟物”质感的指针仪表了。

要想实现本文封面上的电流表,难度并不在如何把绘制有表盘刻度的背景图片拷贝到屏幕上——在LCD上显示图片谁不会呢?关键还在于以下三个层层递进的问题:

  1. 如何用传感器采样获得的数值驱动指针指向对应的刻度

换句话说,如何把任意数值转化为指针旋转的角度,并用这一角度信息来旋转表征指针的图片素材;

  1. 如何模拟出类似实体仪表指针那种物理特性?

也就是说,当传感器采样结果具有较大变化时,指针不是直接就指向对应角度,而是有一个类似超调、回调——在目标值附近来回摆动几下才逐渐停止的物理质感。

  1. 如何在资源受限、频率较低的MCU上让指针的运动显得流畅而自然?

v1.2.0版本开始,Arm-2D引入了一个全新的辅助控件:meter_pointer_t,以极其简单直接的接口,一口气同时解决了上述三个问题。借助该控件,我们可以轻松的实现下面视频所展示的效果:

image.png

值得说明的是,上述例子中为了展示指针在跳变时刻的“摆动”效果,特意用下面的代码每隔3秒钟就随机产生一个0~200之间的随机整数,并以此来驱动指针的运动:

    do {
        /* 产生一个3000ms的定时 */
        if (arm_2d_helper_is_time_out(3000,  
            &this.lTimestamp[1])) {

            /* 复位软件定时器 */
            this.lTimestamp[1] = 0;

            /* 初始化随机数发生器 */
            srand(arm_2d_helper_get_system_timestamp());

            /* 产生一个 0 到 200 之间的随机整数 */
            this.iTargetNumber = rand() % 200;
        }

        /* 更新 指针 */
        meter_pointer_on_frame_start(
            &this.tMeterPointer,    /* 指针控件 */
            this.iTargetNumber,     /* 指针的值 */
            1.0f);                  /* 指针的缩放比例 */
    } while(0);

那么,这一切是如何做到的呢?我们又如何使用 meter_pointer_t 来解决上述三个问题呢?不着急,请听我娓娓道来。

image.png

在介绍 meter_pointer_t 控件的使用之前,我需要假设您:

  1. 已经通过前面的文章《入门和移植从未如此简单》将Arm-2D移植到了你本地的(硬件)平台上;
  2. 通过文章《Arm-2D应用开发入门》了解运用Arm-2D进行GUI应用开发的基础框架——知道场景播放器(Scene Player)、知道如何创建自己的场景(Scene);
  3. 至少阅读过文章《零基础Arm-2D API绘图入门无忧》了解了Arm-2D绘图API的基本使用套路,知道Mask的意义和作用。
  4. 基本掌握了文章《还在手算坐标?试试Layout Assistant吧!》所介绍的界面布局辅助工具的使用。

如果您还没来得及阅读上述内容,不妨先“单击这里”进入文章列表吧。

【meter_pointer_t 控件的部署】

为了使用 meter_pointer_t 控件,需要在我们的目标场景头文件里加入对例子控件的引用:

#include "arm_2d_example_controls.h"

这样,我们就可以在场景的类中为 meter_pointer_t 添加成员变量,例如:

/*!
 * \brief a user class for scene meter
 */
typedef struct user_scene_meter_t user_scene_meter_t;

struct user_scene_meter_t {
    ...

ARM_PRIVATE(
    /* place your private member here, following two are examples */
    ...
    
    meter_pointer_t tMeterPointer;

)
    /* place your public member here */
    ...
};

接下来,我们需要在场景的C源文件中分别找到以下的事件处理程序,并添加 meter_pointer_t 类所对应的方法:

  • 在“场景载入(on-load)”事件处理程序 __on_scene_xxxx_load 中添加 meter_pointer_on_load() 处理程序,例如:
static void __on_scene_meter_load(arm_2d_scene_t *ptScene)
  • 在“场景卸载(on-depose)”事件处理程序 __on_scene_xxxx_depose 中添加 meter_pointer_depose() 处理程序,例如:
static void __on_scene_meter_load(arm_2d_scene_t *ptScene)
{
    user_scene_meter_t *ptThis = (user_scene_meter_t *)ptScene;
    ARM_2D_UNUSED(ptThis);

    meter_pointer_on_load(&this.tMeterPointer);

}
  • 在“当前帧刷新完毕(on-frame-complete)”事件处理程序 __on_scene_xxxx_frame_complete 中添加 meter_pointer_on_frame_complete() 处理程序,例如:
static void __on_scene_meter_depose(arm_2d_scene_t *ptScene)
{
    user_scene_meter_t *ptThis = (user_scene_meter_t *)ptScene;
    ARM_2D_UNUSED(ptThis);

    ...

    meter_pointer_depose(&this.tMeterPointer);

    if (!this.bUserAllocated) {
        __arm_2d_free_scratch_memory(ARM_2D_MEM_TYPE_UNSPECIFIED, ptScene);
    }
}

在完成上述“格式性”的操作后,我们要来处理三个至关重要的步骤:

  • meter_pointer_t 的初始化和配置;
  • 当我们获得传感器返回值时,如何借助 meter_pointer_t 来更新指针的位置;
  • 如何用 meter_pointer_t 来绘制指针

【meter_pointer_t 控件的初始化和配置】

和其它控件类似,meter_pointer_t 也是在场景的初始化函数 __arm_2d_scene_xxxx_init() 中进行的,例如:


ARM_NONNULL(1)
user_scene_meter_t *__arm_2d_scene_meter_init(   arm_2d_scene_player_t *ptDispAdapter, 
                                        user_scene_meter_t *ptThis)
{
    bool bUserAllocated = false;
    assert(NULL != ptDispAdapter);

    ...

    *ptThis = (user_scene_meter_t){
        .use_as__arm_2d_scene_t = {
        
            ...

            .bUseDirtyRegionHelper = true,
        },
        .bUserAllocated = bUserAllocated,
    };

    do {
        meter_pointer_cfg_t tCFG = {
            .tSpinZoom = {

                ...

                .ptScene = (arm_2d_scene_t *)ptThis,
            },
            ...
        };
        meter_pointer_init(&this.tMeterPointer, &tCFG);

    } while(0);

    ...

    arm_2d_scene_player_append_scenes(  ptDispAdapter, 
                                        &this.use_as__arm_2d_scene_t, 
                                        1);

    return ptThis;
}

这里,有三个细节需要注意:

  • 针对 meter_pointer_t 的初始化要放在“针对当前场景(scene)的初始化”之后“将当前场景添加到场景播放器(scene player)”之前进行;
  • meter_pointer_t 的初始化是通过调用方法函数 meter_pointer_init() 来实现的。
ARM_NONNULL(1,2)
arm_2d_err_t meter_pointer_init(meter_pointer_t *ptThis,
                                meter_pointer_cfg_t *ptCFG)

第一个参数是指向 meter_pointer_t 实例/变量 的指针;第二个参数是 meter_pointer_cfg_t 类型的结构体。在上述例子代码中,我们会定义一个名为 tCFGmeter_pointer_cfg_t 局部变量来设置 meter_pointer_t 的各项参数。

    do {
        meter_pointer_cfg_t tCFG = {
            .tSpinZoom = {
                ...
            },
            ...
        };
        meter_pointer_init(&this.tMeterPointer, &tCFG);
    } while(0);
  • 为了使用 meter_pointer_t 内置的动态脏矩阵功能,我们需要在“当前场景初始化”中将“UseDirtyRegionHelper”开关设置为"true";在配置结构体中将 tCFG.tSpinZoom.ptScene 赋值为 "(arm_2d_scene_t * )ptThis"。

完成上述设置后,meter_pointer_t 会自动替我们处理好动态脏矩阵——获得最佳的刷新帧率。你可以在 arm_2d_disp_adapter_0.h 中打开脏矩阵调试模式来观察效果:

image.png

image.png

针对 meter_pointer_t 的配置,集中体现在 meter_pointer_cfg_t 结构体中:

typedef struct meter_pointer_cfg_t {
    implement_ex(spin_zoom_widget_cfg_t, tSpinZoom);

    struct {
        bool bIsSourceHorizontal;
        int16_t iRadius;
    } Pointer;

    arm_2d_helper_pi_slider_cfg_t tPISliderCFG;
    int32_t nPISliderStartPosition;

} meter_pointer_cfg_t;

由于 meter_pointer_t 实际上是另外一个类 spin_zoom_widget_t 的派生类,因此它的配置结构体也派生自基类的配置结构体:spin_zoom_widget_cfg_t ,它的定义如下:

typedef struct spin_zoom_widget_cfg_t {
    arm_2d_scene_t *ptScene;

    struct {
        const arm_2d_tile_t     *ptMask;
        const arm_2d_tile_t     *ptSource;
        arm_2d_location_t       tCentre;
        union {
            COLOUR_INT_TYPE     tColourForKeying;
            COLOUR_INT_TYPE     tColourToFill;
        };
    } Source;

    spin_zoom_widget_mode_t *ptTransformMode;

    struct {
        struct {
            float fAngleInDegree;
            int32_t nValue;
        } LowerLimit;

        struct {
            float fAngleInDegree;
            int32_t nValue;
        } UpperLimit;

        struct {
            float fAngle;
            float fScale;
        } Step;
    } Indicator;

} spin_zoom_widget_cfg_t;

该结构体看似复杂,其实很简单。

它的配置主要分成两个重要组成部分:

  • “对于指针资源的描述(.Source)”和我们要使用的“Transform模式(.ptTransformMode)”;

这里所谓的 “Transform模式” 是通过 ptTransformMode 来指定的。可选项有:

SPIN_ZOOM_MODE_FILL_COLOUR:在对 mask 进行旋转和缩放的同时进行颜色填充——这是最适合仪表的模式。在这一模式下,".Source.ptMask" 需要指向素材的蒙版,并通过“.Source.tColourToFill”来指定指针的颜色。

SPIN_ZOOM_MODE_TILE_WITH_MASK: 对拥有专门“蒙版(Mask)”的图片(类似PNG图片)进行旋转和缩放——适合“花里胡哨”的指针。在这一模式下,".Source.ptMask" 和".Source.ptSource" 要分别指向素材的蒙版和保存像素素材的贴图。

SPIN_ZOOM_MODE_TILE_WITH_COLOUR_KEYING: 用一种用户指定的颜色对图片进行抠图的同时进行旋转和缩放操作——适合“背景色”与指针素材图片的抠图色相同的指针,也适合指针“花里胡哨”的情况。在这一模式下,".Source.ptSource" 要指向保存像素素材的贴图,并通过“.Source.tColourForKeying”来设置用于在素材中进行“抠图(Keying)”的颜色。

SPIN_ZOOM_MODE_TILE_ONLY: 但纯对图片素材进行旋转和缩放,既没有蒙版,也没有抠图——不太适合仪表指针的显示。在这一模式下,".Source.ptSource" 只需要指向保存像素素材的贴图即可。

好奇的小伙伴可能会奇怪 “.Source.tCentre” 的作用。它其实并不复杂。Arm-2D中的旋转操作与我们日常生活中往墙上钉图片类似——如果只用一个图钉,那么图片就可以绕着图钉为圆心进行旋转。如果我们再将图钉取下来,会发现墙上有一个洞——我们称之为目标画布上的“圆心(Pivot)”;图片上也会有一个洞——我们称之为素材上的“旋转中心(Centre)”

聪明的你一定猜到了——这里的“.Source.tCentre”就是用来设置素材上的旋转中心的。

image.png

值得强调的是,由于 meter_pointer_t 用了更加友好的方法来设置指针素材的旋转中心,因此,这里的“.Source.tCentre”不用专门设置(即便你设置了,后面也会被覆盖)。

meter_pointer_cfg_t 中,我们是通过下面的结构来描述指针素材的:

typedef struct meter_pointer_cfg_t {
    ...

    struct {
        bool bIsSourceHorizontal;
        int16_t iRadius;
    } Pointer;
    
    ...

} meter_pointer_cfg_t;

如果你的指针素材是横着的(也就是width大于height),就把 ".Pointer.bIsSourceHorizontal" 设置为true,反之则设置为 false

接着,根据你屏幕上指针旋转中心到指针末梢的距离(也就是指针的旋转半径)来设置 “.Pointer.iRadius” 。

需要特别强调的是:iRadius 的值“可以”与指针素材的尺寸无关。比如,对于例子视频中的指针,显然指针的高度(height)是小于指针的旋转半径的,此时我们只需要根据需要将实际的旋转半径赋值给“.Pointer.iRadius” 就能实现视频中“悬空指针”的效果。

  • 对仪表盘中角度和仪表数值线性映射关系的设置(Indicator)

Indicator中,我们需要分别设置:

  • 指针的“起始角度(.LowerLimit.fAngleInDegree)”——顾名思义,指针起始的角度(注意不是弧度),这是一个float型数据;
  • 指针指向“起始角度”时对应的“仪表数值下限(LowerLimit.nValue)”,这是一个int32_t型数据;
  • 指针的“终止角度(.UpperLimit.fAngleInDegree)”——顾名思义,指针终止的角度;
  • 指针指向“终止角度”时对应的“仪表数值上线(UpperLimit.nValue)”,这是一个int32_t型数据;

比如,视频对应的配置如下:

meter_pointer_cfg_t tCFG = {

    .tSpinZoom = {
        .Indicator = {
            .LowerLimit = {
                .fAngleInDegree = -120.0f,
                .nValue = 0,
            },
            .UpperLimit = {
                .fAngleInDegree = 100.0f,
                .nValue = 200,
            },
        },

        .ptTransformMode = &SPIN_ZOOM_MODE_FILL_COLOUR,
        .Source = {
            .ptMask = &c_tilePointerMask,
            .tColourToFill = GLCD_COLOR_RED,
        },

        ...
    },
    ...

};

image.png

可见,图中的仪表的有效值是 0~200,分别对应 - 120°和100°。与现实中的仪表一样,由于指针在遇到物理限制之前是可以自由移动的,视频中的指针在停止运动前,是有可能超过上述范围的。

指针的物理效果是通过 tPISliderCFG 来配置的。

typedef struct meter_pointer_cfg_t {

    ...

    arm_2d_helper_pi_slider_cfg_t tPISliderCFG;
    int32_t nPISliderStartPosition;

} meter_pointer_cfg_t;

值得说明的是:对普通应用来说,请忽略tPISliderCFG”的配置——meter_pointer_t会采用一个适合大部分应用的默认值。一些有兴趣、且有特殊要求的小伙伴可以去自行调参,希望你们能找回大学时上自控原理PID实验课时候的乐趣——虽然这里只用到了PI而已。

至此,我们获得了一个完整的 meter_pointer_t 初始化和配置代码:

do {
    meter_pointer_cfg_t tCFG = {
        .tSpinZoom = {

            /* 设置角度与数值的映射关系 */
            .Indicator = {
                .LowerLimit = {
                    .fAngleInDegree = -120.0f,
                    .nValue = 0,
                },
                .UpperLimit = {
                    .fAngleInDegree = 100.0f,
                    .nValue = 200,
                },
            },

            /* 设置指针的绘制模式 */
            .ptTransformMode = &SPIN_ZOOM_MODE_FILL_COLOUR,
            .Source = {
                .ptMask = &c_tilePointerMask,
                .tColourToFill = GLCD_COLOR_RED,
            },

            /* 开启脏矩阵的必要步骤之一 */
            .ptScene = (arm_2d_scene_t *)ptThis,
        },

        /* 设置指针 */
        .Pointer = {
            .bIsSourceHorizontal = false, /* 素材是竖着的 */
            .iRadius = 100, /* 旋转半径 */
        },

    };
    meter_pointer_init(&this.tMeterPointer, &tCFG);

} while(0);

image.png

【如何绘制指针】

指针的绘制通常是在场景的 on-draw 事件处理程序中进行的。借助方法函数 meter_pointer_show() 我们可以轻松的将指针显示在屏幕的指定位置,例如:

static
IMPL_PFB_ON_DRAW(__pfb_draw_scene_meter_handler)
{
    user_scene_meter_t *ptThis = (user_scene_meter_t *)pTarget;

    ARM_2D_UNUSED(ptTile);
    ARM_2D_UNUSED(bIsNewFrame);

    arm_2d_canvas(ptTile, __canvas) {

        /* 将表盘居中 */
        arm_2d_align_centre(__canvas, c_tileMeterPanel.tRegion.tSize) {

            /* 绘制仪表表盘 */
            arm_2d_tile_copy_only(  &c_tileMeterPanel,
                                    ptTile,
                                    &__centre_region);

            ARM_2D_OP_WAIT_ASYNC();

            /* 绘制指针 */
            meter_pointer_show(
                &this.tMeterPointer,
                ptTile,              /* 目标画布 */
                &__centre_region,    /* 指针在画布中的位置 */
                NULL,                /* 指针在画布上的旋转中心 */
                255);                /* Opacity */
        }

        ...
    }
    ARM_2D_OP_WAIT_ASYNC();

    return arm_fsm_rt_cpl;
}

可以看到,meter_pointer_show() 的使用并不复杂,它的函数原型如下:

extern
ARM_NONNULL(1, 2)
void meter_pointer_show(meter_pointer_t *ptThis,
                        const arm_2d_tile_t *ptTile,
                        const arm_2d_region_t *ptRegion,
                        const arm_2d_location_t *ptPivot,
                        uint8_t chOpacity);

这里,抛开指向 meter_pointer_t 对象的 ptThis 指针不表,ptTileptRegionchOpacity都是大家熟悉的老生常谈了。唯一值得说明的是:ptPivot用于指定指针在目标画布上的旋转中心,如果传递NULL,则直接使用 ptRegion 指定区域的中心作为旋转中心。

【如何让指针指向仪表的正确位置】

经过上述步骤,我们指针已经可以正确显示在屏幕上了,正所谓“万事俱备只欠东风”,只需要将传感器采样到的结果传送给 meter_pointer_t 就行了。

这里,我们需要借助函数 meter_pointer_on_frame_start(),它的函数原型如下:

extern
ARM_NONNULL(1)
bool meter_pointer_on_frame_start(  
    meter_pointer_t *ptThis, 
    int32_t nTargetValue, 
    float fScale);

这里,nTargetValue 就是你要更新的数值;fScale是指针的缩放比例(一般填写1.0f),该函数需要在场景的 on-frame-start 事件处理程序中调用,比如:

static void __on_scene_meter_frame_start(arm_2d_scene_t *ptScene)
{
    user_scene_meter_t *ptThis = (user_scene_meter_t *)ptScene;
    ARM_2D_UNUSED(ptThis);
    

    meter_pointer_on_frame_start(
        &this.tMeterPointer, 
        <你的传感器采样结果>,
        1.0f);

    ...
}

至此,大功告成。

【如何获取懒人的场景模板】

如果你对自己动手能力没有信心,想从一个完整的例子开始探索,cmsis-pack还为我们在MDK中提供了另外一种选择——通过代码模板来添加 arm_2d_scene_meter 场景模板。具体步骤为:

1、在工程管理器中选中你想添加代码模板的Group,单击右键,弹出菜单:

image.png

2、选择Add New Item to Group

3、在窗口中找到User Code Template。展开Acceleration,选中Arm-2D Helper:PFBScene Template Meter,并在Location后面的"..." 按钮中将例子模板添加到工程目录中。

image.png

4、在MDK工程配置中,添加针对头文件的搜索路径,使其指向 arm_2d_scene_meter.h 所在的目录。

5、在main.c 中添加头文件:

#include "arm_2d_scene_meter.h"

这样,我们就可以通过对应的初始化代码将arm_2d_scene_meter添加到场景播放器中了,例如:

...
arm_2d_scene_meter_init(&DISP_ADAPTER0);
arm_2d_scene_player_switch_to_next_scene(&DISP0_ADAPTER);
...

【说在后面的话】

Arm-2Dv1.0.0时代开始就为MCU提供了完善的旋转和缩放的功能,并在随后的版本中逐步添加了动态脏矩阵以优化指针类应用的帧率。为了降低用户开发应用的难度,Arm-2D又很快加入了 arm_2d_scene_meter 场景模板——真正做到了用户简单修改、替换下素材就能直接拿来用的程度。

至此,虽然已经到达了“可用”的程度,但仍然不够简单——因为用户要分别处理指针显示动态脏矩阵两个部分。

为了解决这一问题,提供一站式服务,Arm-2Dv1.2.0开始提供了 spin_zoom_widget_t 控件——处理一切与transform有关的操作,并将transform及脏矩阵的操作都隐藏在了控件的内部。而针对仪表指针的特殊情况,Arm-2D又在 spin_zoom_widget_t 的基础上派生出了 meter_pointer_t 控件——简化了用户的配置、并贴心的加入了模拟指针物理特性的PIHelper,至此,从提升用户体验的角度来说,可谓大功告成。

其实,不光是 arm_2d_scene_meter 展示了 meter_pointer_t 的应用方法,在Arm-2D自带的Demo中,watch face 01 更是借助 meter_pointer_t 的物理效果模拟了机械挂钟的秒针“一步一颤”的质感:

image.png

image.png

“至此,已成艺术!”

            —— 一个用在这里极其不恰当的梗

作者:GorgonMeducer 傻孩子
原文:裸机思维

专栏推荐文章

如果你喜欢我的思维,欢迎订阅裸机思维欢迎添加极术小姐姐微信(id:aijishu20)加入技术交流群,请备注研究方向。
推荐阅读
关注数
1476
内容数
116
探讨嵌入式系统开发的相关思维、方法、技巧。
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息