小型设备上的有状态屏幕渲染





5.00/5 (7投票s)
如何制作高度响应、无闪烁的交互式屏幕,而又不占用大量内存
引言
请耐心听我讲,因为我们要解决的问题最终相当复杂。这就像“杰克盖的房子”一样,即使描述这个问题也需要一些功夫。
Espressif 在其最新的 Arduino 框架实现以及 ESP32 系列 MCU 上的 ESP-IDF 中,几乎把我逼入了绝境。本质上,你只能在非常有限的场景下从显示器的帧缓冲中读回数据,而且通常很麻烦,即使在可以读回的情况下也是如此。
本质上,这意味着在直接绘制到屏幕时,不支持抗锯齿的 True Type 字体。最根本的问题是,为了进行 alpha 混合或抗锯齿绘制操作,你必须先绘制到一个位图,然后将该位图吐送到显示器。
这对于小的绘制操作来说是可以的,但如果以这种方式构建整个屏幕,就会产生问题,因为你通常没有足够的 RAM 来将整个屏幕保存在一个位图中。
但是,如果我们能够存储绘制操作,然后在屏幕变化时重新绘制部分屏幕呢?我们不需要整个帧缓冲区的位图,因为我们可以根据需要重新绘制屏幕的一部分。因此,我们可以分段绘制屏幕。
通过这种方式,你永远不需要从帧缓冲区读回数据。抗锯齿和 alpha 混合是支持的,因为你总是会绘制到一个内存中的位图,该位图是显示区域的子集,并且你可以根据需要重新绘制你正在混合的区域下方的项目。
通过创建控件/组件库,你可以解决这些问题。
必备组件
我使用的是 Makerfabs 的 ESP Display S3 Parallel w/ Touch。你可以使用不同的单元,但你需要更新代码的很大一部分来匹配你的显示器和触摸屏驱动程序。
你需要安装了 Platform IO 扩展的 Visual Studio Code。
理解这段乱码
这里的关键是渲染控件。每个控件都有一个 `on_paint()` 回调函数,它将控件的视觉元素绘制到 htcw_gfx 中的一个绘制目标上,并带有一个特定的裁剪矩形。控件可以使用此信息来渲染自身。我们将渲染到一个位图。
每个屏幕处理自己的渲染,并包含一个 `control<>` 指针列表。你可以创建控件,然后将它们注册到屏幕。控件以 Z 顺序(从后到前)注册和存储。
我们使用“脏矩形”的概念来跟踪 `screen<>` 中变脏的区域。`control<>` 有一个 `invalidate()` 方法,它会将控件的边界区域添加到脏矩形列表中。任何时候控件的状态发生变化,它通常会使自身失效,强制最终重绘控件的全部或部分。
在渲染时,我们遍历脏矩形列表。当我们找到一个脏矩形时,我们会计算所需的位图大小,该大小受限于可用写缓冲器的大小。这可能小于脏矩形的大小,因此我们会一次分块渲染脏矩形的一部分,从上到下。
为了渲染一个子矩形,对于每个子矩形,我们枚举屏幕上的控件,找到与该子矩形相交的控件。当我们找到一个控件时,我们在位图上创建一个转换窗口,使得要绘制到位图的区域在坐标上进行偏移,以便控件的绘制始终从 (0,0) 开始。我们还计算一个裁剪矩形,控件的 `on_paint()` 例程可以使用该矩形来确定它需要重绘的部分。当我们渲染完所有与子矩形相交的控件后,我们将该子矩形的位图发送到显示器。
现在,应该注意的是,此例程可以使用两个缓冲区。如果这样做,每次将位图发送到显示器时,它都会切换正在使用的活动缓冲区。这样,DMA 功能的系统就可以在后台发送数据,而例程可以继续进行绘制,以获得最大的性能。
使用这个烂摊子
如果你曾经使用过 .NET 中的 WinForms 设计器,控件的设置代码与设计器生成的代码中的 `InitializeComponent()` 例程相比,可能看起来有些熟悉。基本上,你创建控件,设置一系列控件属性,然后将其添加到控件集合中。我们在这里将这样做。不幸的是,目前我们没有可视化设计器,所以这一切都是手动的。
让我们来看看控件和屏幕的初始化。
// set the screen
main_screen.background_color(color_t::white);
main_screen.on_flush_callback(uix_flush,nullptr);
main_screen.on_touch_callback(uix_touch,nullptr);
// set the label
test_label.bounds(srect16(spoint16(10,10),ssize16(200,60)));
test_label.text_color(color32_t::blue);
test_label.text_open_font(&text_font);
test_label.text_line_height(50);
test_label.text_justify(uix_justify::center);
test_label.round_ratio(NAN);
test_label.padding({8,8});
test_label.text("Hello");
main_screen.register_control(test_label);
// set the button
test_button.bounds(srect16(spoint16(25,25),ssize16(200,100)));
// we'll do alpha blending, so
// set the opacity to 50%
auto bg = color32_t::light_blue;
bg.channelr<channel_name::A>(.5);
test_button.background_color(bg,true);
test_button.border_color(color32_t::black);
test_button.text_color(color32_t::black);
test_button.text_open_font(&text_font);
test_button.text_line_height(25);
test_button.text_justify(uix_justify::center);
test_button.round_ratio(NAN);
test_button.padding({8,8});
test_button.text("Released");
test_button.on_pressed_changed_callback(
[](bool pressed,void* state) {
test_button.text(pressed?"Pressed":"Released");
});
main_screen.register_control(test_button);
这里没有太复杂的东西,只是数量很多。我们最复杂的操作是通过将按钮的 A/alpha 通道设置为 50% 来 alpha 混合按钮的背景。
在 `loop()` 中,我们只需调用 `main_screen.update()`,让它有机会进行渲染和处理触摸。
编写这个混乱的程序
现在让我们进入有趣的部分,深入了解它是如何工作的。
首先,我们将介绍控件框架,位于 * /lib/htcw_uix/include/uix_core.hpp:
template<typename PixelType,typename PaletteType = gfx::palette<PixelType,PixelType>>
class control {
public:
using type = control;
using pixel_type = PixelType;
using palette_type = PaletteType;
using control_surface_type = control_surface<pixel_type,palette_type>;
private:
srect16 m_bounds;
const PaletteType* m_palette;
bool m_visible;
invalidation_tracker& m_parent;
control(const control& rhs)=delete;
control& operator=(const control& rhs)=delete;
protected:
control(invalidation_tracker& parent, const palette_type* palette = nullptr) :
m_bounds({0,0,49,24}),
m_palette(palette),
m_visible(true),
m_parent(parent) {
}
void do_move_control(control& rhs) {
m_bounds = rhs.m_bounds;
m_palette = rhs.m_palette;
m_visible = rhs.m_visible;
m_parent = rhs.m_parent;
}
public:
control(control&& rhs) {
do_move_control(rhs);
}
control& operator=(control&& rhs) {
do_move_control(rhs);
}
const palette_type* palette() const {return m_palette;}
ssize16 dimensions() const {
return m_bounds.dimensions();
}
srect16 bounds() const {
return m_bounds;
}
virtual void bounds(const srect16& value) {
if(m_visible) {
m_parent.invalidate(m_bounds);
m_parent.invalidate(value);
}
m_bounds = value;
}
virtual void on_paint(control_surface_type& destination,const srect16& clip) {
}
virtual void on_touch(size_t locations_size,const spoint16* locations) {
};
virtual void on_release() {
};
bool visible() const {
return m_visible;
}
void visible(bool value) {
if(value!=m_visible) {
m_visible = value;
return this->invalidate();
}
}
uix_result invalidate() {
return m_parent.invalidate(m_bounds);
}
uix_result invalidate(const srect16& bounds) {
srect16 b = bounds.offset(this->bounds().location());
if(b.intersects(this->bounds())) {
b=b.crop(this->bounds());
return m_parent.invalidate(b);
}
return uix_result::success;
}
};
你可以看到一些基本的函数用于报告触摸、使控件无效、属性以及 `on_paint()`。
模板参数需要解释一下。htcw_gfx 是显示器无关的,并在显示器的原生像素格式中进行渲染。这可能是 4 位灰度(用于黑、白、灰电子纸),甚至是小型调色板(用于彩色电子纸)。在某些情况下,这甚至可以是 24 位彩色显示器,但通常是 16 位 RGB565。传递给模板的参数指示了显示器的原生格式以及任何调色板类型。无论这些参数是什么,都决定了屏幕渲染过程中生成的位图数据的格式。
无效化例程会将脏矩形添加到屏幕中。一个调用只是添加整个控件窗口,而另一个则使控件内的特定矩形无效。
从渲染例程调用 `on_paint()` 需要特别考虑。被调用者/控件期望其左上角坐标从 (0,0) 开始,并延伸到控件的尺寸。我们可以通过以特定量偏移它们来重新映射绘图操作,以实现这一点。
为此,需要创建一个自定义的 htcw_gfx 绘制目标。这实际上并不复杂。你只需要实现几个方法,其中大多数都相当直接。我们将通过创建一个 `control_surface<>` 模板类来实现这一点,该类会在 `bitmap<>` 上重新映射所有绘图操作。
template<typename PixelType,typename PaletteType = gfx::palette<PixelType,PixelType>>
class control_surface final {
public:
using type = control_surface;
using pixel_type = PixelType;
using palette_type = PaletteType;
using bitmap_type= gfx::bitmap<pixel_type,palette_type>;
using caps = gfx::gfx_caps<false,false,false,false,false,true,false>;
private:
bitmap_type& m_bitmap;
srect16 m_rect;
void do_move(control_surface& rhs) {
m_bitmap = rhs.m_bitmap;
m_rect = rhs.m_rect;
}
control_surface(const control_surface& rhs)=delete;
control_surface& operator=(const control_surface& rhs)=delete;
public:
control_surface(control_surface&& rhs) : m_bitmap(rhs.m_bitmap) {
do_move(rhs);
}
control_surface& operator=(control_surface&& rhs) {
do_move(rhs);
return *this;
}
control_surface(bitmap_type& bmp,const srect16& rect) : m_bitmap(bmp) {
m_rect = rect;
}
const palette_type* palette() const {
return m_bitmap.palette();
}
size16 dimensions() const {
return (size16)m_rect.dimensions();
}
rect16 bounds() const {
return rect16(point16::zero(),dimensions());
}
gfx::gfx_result point(point16 location, pixel_type* out_pixel) const {
location.offset_inplace(m_rect.x1,m_rect.y1);
return m_bitmap.point(location,out_pixel);
}
gfx::gfx_result point(point16 location, pixel_type pixel) {
spoint16 loc = ((spoint16)location).offset(m_rect.x1,m_rect.y1);
return m_bitmap.point((point16)loc,pixel);
return gfx::gfx_result::success;
}
gfx::gfx_result fill(const rect16& bounds, pixel_type pixel) {
if(bounds.intersects(this->dimensions().bounds())) {
srect16 b = ((srect16)bounds);
b=b.offset(m_rect.x1,m_rect.y1);
if(b.intersects((srect16)m_bitmap.bounds())) {
b=b.crop((srect16)m_bitmap.bounds());
return m_bitmap.fill((rect16)b,pixel);
}
}
return gfx::gfx_result::success;
}
gfx::gfx_result clear(const rect16& bounds) {
return fill(bounds,pixel_type());
}
};
实际上并不那么复杂,除了在 `fill` 操作中必须裁剪。它只是将偏移后的坐标转发给基于你给定的 `rect` 的底层位图。
现在让我们继续讨论 `screen<>` 的 `update()` 方法。我们将只覆盖其中的一部分,因为它是一个大类,并且其中很大一部分将在我们介绍的内容中得到解释。文件是 * /lib/htcw_uix/include/uix_screen.hpp:
uix_result update(bool full = true) {
uix_result res = update_impl();
if(res!=uix_result::success) {
return res;
}
while(full && m_it_dirties!=nullptr) {
res = update_impl();
if(res!=uix_result::success) {
return res;
}
}
return uix_result::success;
}
你可以看到其中的真正核心是 `update_impl()`,我们无条件地调用一次,然后重复调用,直到 `m_it_dirties` 为 null,这告诉我们渲染过程已完成。
基本上,`update_impl()` 是一个处理触摸处理和渲染的协程。当它渲染时,它将渲染过程分解为子矩形,并在每次调用时渲染其中一个。在渲染之间,它会处理触摸输入。现在我们将通过介绍 `update_impl()` 来进行实际的渲染。
uix_result update_impl() {
// if not rendering, process touch
if(m_it_dirties==nullptr&& m_on_touch_callback!=nullptr) {
point16 locs[2];
spoint16 slocs[2];
size_t locs_size = sizeof(locs);
m_on_touch_callback(locs,&locs_size,m_on_touch_callback_state);
if(locs_size>0) {
// if we currently have a touched control
// forward all successive messages to that control
// even if they're outside the control bounds.
// that way we can do dragging if necessary.
// this works like MS Windows.
if(m_last_touched!=nullptr) {
// offset the touch points to the control and then
// call on_touch for the control
for(int i = 0;i<locs_size;++i) {
slocs[i].x = locs[i].x-(int16_t)m_last_touched->bounds().x1;
slocs[i].y = locs[i].y-(int16_t)m_last_touched->bounds().y1;
}
m_last_touched->on_touch(locs_size,slocs);
} else {
// loop through the controls in z-order back to front
// find the last/front-most control whose bounds()
// intersect the first touch point
control_type* target = nullptr;
for(control_type** ctl_it = m_controls.begin();ctl_it!=m_controls.end();++ctl_it) {
control_type* pctl = *ctl_it;
if(pctl->visible() && pctl->bounds().intersects((spoint16)locs[0])) {
target = pctl;
}
}
// if we found one make it the current control, offset the touch
// points to the control and then call on_touch for the control
if(target!=nullptr) {
m_last_touched = target;
for(int i = 0;i<locs_size;++i) {
slocs[i].x = locs[i].x-(int16_t)target->bounds().x1;
slocs[i].y = locs[i].y-(int16_t)target->bounds().y1;
}
target->on_touch(locs_size,slocs);
}
}
} else {
// released. if we have an active control let it know.
if(m_last_touched!=nullptr) {
m_last_touched->on_release();
m_last_touched = nullptr;
}
}
}
// rendering process
// note we skip this until we have a free buffer
if(m_on_flush_callback!=nullptr &&
m_flushing<(1+(m_buffer2!=nullptr)) &&
m_dirty_rects.size()!=0) {
if(m_it_dirties==nullptr) {
// m_it_dirties is null when not rendering
// so basically when it's null this is the first call
// and we initialize some stuff
m_it_dirties = m_dirty_rects.cbegin();
size_t bmp_stride = bitmap_type::sizeof_buffer(size16(m_it_dirties->width(),1));
m_bmp_lines = m_buffer_size/bmp_stride;
if(bmp_stride>m_buffer_size) {
return uix_result::out_of_memory;
}
m_bmp_y = 0;
} else {
// if we're past the current
// dirty rectangle bounds:
if(m_bmp_y+m_it_dirties->y1+m_bmp_lines>m_it_dirties->y2) {
// go to the next dirty rectangle
++m_it_dirties;
if(m_it_dirties==m_dirty_rects.cend()) {
// if we're at the end, shut it down
// and clear all dirty rects
m_it_dirties = nullptr;
return validate_all();
}
// now we compute the bitmap stride (one line, in bytes)
size_t bmp_stride = bitmap_type::sizeof_buffer(size16(m_it_dirties->width(),1));
// now we figure out how many lines we can have in these
// subrects based on the total memory we're working with
m_bmp_lines = m_buffer_size/bmp_stride;
// if we don't have enough space for at least one line,
// error out
if(bmp_stride>m_buffer_size) {
return uix_result::out_of_memory;
}
// start at the top of the dirty rectangle:
m_bmp_y = 0;
} else {
// move down to the next subrect
m_bmp_y+=m_bmp_lines;
}
}
// create a subrect the same width as the dirty, and m_bmp_lines high
// starting at m_bmp_y within the dirty rectangle
srect16 subrect(m_it_dirties->x1,m_it_dirties->y1+m_bmp_y,m_it_dirties->x2, m_it_dirties->y1+m_bmp_lines+m_bmp_y-1);
// make sure the subrect is cropped within the bounds
// of the dirties. sometimes the last one overhangs.
subrect=subrect.crop((srect16)*m_it_dirties);
// create a bitmap for the subrect over the write buffer
bitmap_type bmp((size16)subrect.dimensions(),m_write_buffer,m_palette);
// fill it with the screen color
bmp.fill(bmp.bounds(),m_background_color);
// for each control
for(control_type** ctl_it = m_controls.begin();ctl_it!=m_controls.end();++ctl_it) {
control_type* pctl = *ctl_it;
// if it's visible and intersects this subrect
if(pctl->visible() && pctl->bounds().intersects(subrect)) {
// create the offset surface rectangle for drawing
srect16 surface_rect = pctl->bounds();
surface_rect.offset_inplace(-subrect.x1,-subrect.y1);
// create the clip rectangle for the control
srect16 surface_clip = pctl->bounds().crop(subrect);
surface_clip.offset_inplace(-pctl->bounds().x1,-pctl->bounds().y1);
// create the control surface
control_surface_type surface(bmp,surface_rect);
// and paint
pctl->on_paint(surface,surface_clip);
}
}
// tell it we're flushing and run the callback
++m_flushing;
m_on_flush_callback((point16)subrect.top_left(),bmp,m_on_flush_callback_state);
// the above may return immediately before the
// transfer is complete. To take advantage of
// this, rather than wait, we swap out to a
// second buffer and continue drawing while
// the transfer is in progress.
switch_buffers();
}
return uix_result::success;
}
如你所见,我已对其进行了注释,以概述该方法。由于查看代码旁边对代码正在做什么的描述要容易得多,而且由于我之前已经描述过该过程,因此这里没有太多其他内容需要介绍。
祝您编码愉快!
历史
- 2023年2月26日 - 首次提交
- 2023年2月27日 - 错误修复