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

文摘   2024-09-10 12:09   英国  


【说在前面的话】

说起来挺“幽默”的:当大家“吭哧吭哧”给各类仪表完成从模拟、机械到数字化的转变后,不满于断码显示的“土气”,客户开始喜欢那些在340*240甚至是240*240的小彩屏上追求拥有“复古拟物”质感的指针仪表了。
要想实现本文封面上的电流表,难度并不在如何把绘制有表盘刻度的背景图片拷贝到屏幕上——在LCD上显示图片谁不会呢?关键还在于以下三个层层递进的问题:
  1. 如何用传感器采样获得的数值驱动指针指向对应的刻度
    换句话说,如何把任意数值转化为指针旋转的角度,并用这一角度信息来旋转表征指针的图片素材;

  2. 如何模拟出类似实体仪表指针那种物理特性?
    也就是说,当传感器采样结果具有较大变化时,指针不是直接就指向对应角度,而是有一个类似超调、回调——在目标值附近来回摆动几下才逐渐停止的物理质感。

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


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


值得说明的是,上述例子中为了展示指针在跳变时刻的“摆动”效果,特意用下面的代码每隔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 来解决上述三个问题呢?不着急,请听我娓娓道来。

在介绍 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){    user_scene_meter_t *ptThis = (user_scene_meter_t *)ptScene;    ARM_2D_UNUSED(ptThis);
meter_pointer_on_load(&this.tMeterPointer);
}

  • 在“场景卸载on-depose”事件处理程序 __on_scene_xxxx_depose 中添加 meter_pointer_depose() 处理程序,例如:
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); }}

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


在完成上述“格式性”的操作后,我们要来处理三个至关重要的步骤:
  • 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 中打开脏矩阵调试模式来观察效果:


针对 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”就是用来设置素材上的旋转中心的。




值得强调的是,由于 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, },
...    },    ...
};
可见,图中的仪表的有效值是 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);


【如何绘制指针】

    指针的绘制通常是在场景的 on-draw 事件处理程序中进行的。借助方法函数 meter_pointer_show() 我们可以轻松的将指针显示在屏幕的指定位置,例如:
staticIMPL_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() 的使用并不复杂,它的函数原型如下:
externARM_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(),它的函数原型如下:

externARM_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,单击右键,弹出菜单:

2、选择Add New Item to Group

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

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-2D v1.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 的物理效果模拟了机械挂钟的秒针“一步一颤”的质感:


“至此,已成艺术!”
            —— 一个用在这里极其不恰当的梗


裸机思维
傻孩子图书工作室。探讨嵌入式系统开发的相关思维、方法、技巧。
 最新文章