【说在前面的话】
说起来挺“幽默”的:当大家“吭哧吭哧”给各类仪表完成从模拟、机械到数字化的转变后,不满于断码显示的“土气”,客户开始喜欢那些在340 240甚至是240 240的小彩屏上追求拥有“复古拟物”质感的指针仪表了。
要想实现本文封面上的电流表,难度并不在如何把绘制有表盘刻度的背景图片拷贝到屏幕上——在LCD上显示图片谁不会呢?关键还在于以下三个层层递进的问题:
- 如何用传感器采样获得的数值驱动指针指向对应的刻度
换句话说,如何把任意数值转化为指针旋转的角度,并用这一角度信息来旋转表征指针的图片素材;
- 如何模拟出类似实体仪表指针那种物理特性?
也就是说,当传感器采样结果具有较大变化时,指针不是直接就指向对应角度,而是有一个类似超调、回调——在目标值附近来回摆动几下才逐渐停止的物理质感。
- 如何在资源受限、频率较低的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 控件的使用之前,我需要假设您:
- 已经通过前面的文章《入门和移植从未如此简单》将Arm-2D移植到了你本地的(硬件)平台上;
- 通过文章《Arm-2D应用开发入门》了解运用Arm-2D进行GUI应用开发的基础框架——知道场景播放器(Scene Player)、知道如何创建自己的场景(Scene);
- 至少阅读过文章《零基础Arm-2D API绘图入门无忧》了解了Arm-2D绘图API的基本使用套路,知道Mask的意义和作用。
- 基本掌握了文章《还在手算坐标?试试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 类型的结构体。在上述例子代码中,我们会定义一个名为 tCFG 的 meter_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() 我们可以轻松的将指针显示在屏幕的指定位置,例如:
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 指针不表,ptTile、ptRegion和chOpacity都是大家熟悉的老生常谈了。唯一值得说明的是: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,单击右键,弹出菜单:
2、选择Add New Item to Group。
3、在窗口中找到User Code Template。展开Acceleration,选中Arm-2D Helper:PFB的Scene 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-2D从v1.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 的物理效果模拟了机械挂钟的秒针“一步一颤”的质感:
“至此,已成艺术!”
—— 一个用在这里极其不恰当的梗
作者:GorgonMeducer 傻孩子
原文:裸机思维
专栏推荐文章
如果你喜欢我的思维,欢迎订阅裸机思维欢迎添加极术小姐姐微信(id:aijishu20)加入技术交流群,请备注研究方向。