分类 其它 下的文章

Slate文档编辑器-WrapNode数据结构与操作变换

在之前我们聊到了一些关于
slate
富文本引擎的基本概念,并且对基于
slate
实现文档编辑器的一些插件化能力设计、类型拓展、具体方案等作了探讨,那么接下来我们更专注于文档编辑器的细节,由浅入深聊聊文档编辑器的相关能力设计。

关于
slate
文档编辑器项目的相关文章:

Normalize


slate
中数据结构的规整是比较麻烦的事情,特别是对于需要嵌套的结构来说,例如在本项目中存在的
Quote

List
,那么在规整数据结构的时候就有着多种方案,同样以这两组数据结构为例,每个
Wrap
必须有相应的
Pair
的结构嵌套,那么对于数据结构就有如下的方案。实际上我觉得对于这类问题是很难解决的,嵌套的数据结构对于增删改查都没有那么高效,因此在缺乏最佳实践相关的输入情况下,也只能不断摸索。

首先是复用当前的块结构,也就是说
Quote Key

List Key
都是平级的,同样的其
Pair Key
也都复用起来,这样的好处是不会出现太多的层级嵌套关系,对于内容的查找和相关处理会简单很多。但是同样也会出现问题,如果在
Quote

List
不配齐的情况下,也就是说其并不是完全等同关系的情况下,就会需要存在
Pair
不对应
Wrap
的情况,此时就很难保证
Normalize
,因为我们是需要可预测的结构。

{
    "quote-wrap": true,
    "list-wrap": true,
    children: [
        { "quote-pair": true, "list-pair": 1, children: [/* ... */] },
        { "quote-pair": true, "list-pair": 2, children: [/* ... */] },
        { "quote-pair": true,  children: [/* ... */] },
        { "quote-pair": true, "list-pair": 1, children: [/* ... */] },
        { "quote-pair": true, "list-pair": 2, children: [/* ... */] },
    ]
}

那么如果我们不对内容做很复杂的控制,在
slate
中使用默认行为进行处理,那么其数据结构表达会出现如下的情况,在这种情况下数据结构是可预测的,那么
Normalize
就不成问题,而且由于这是其默认行为,不会有太多的操作数据处理需要关注。但是问题也比较明显,这种情况下数据虽然是可预测的,但是处理起来特别麻烦,当我们维护对应关系时,必须要递归处理所有子节点,在特别多层次的嵌套情况下,这个计算量就颇显复杂了,如果在支持表格等结构的情况下,就变得更加难以控制。

{
    "quote-wrap": true,
    children: [
        {
            "list-wrap": true,
            children: [
                { "quote-pair": true, "list-pair": 1, children: [/* ... */] },
                { "quote-pair": true, "list-pair": 2, children: [/* ... */] },
            ]
        },
        { "quote-pair": true,  children: [/* ... */] },
        { "quote-pair": true,  children: [/* ... */] },
    ]
}

那么这个数据结构实际上也并不是很完善,其最大的问题是
wrap - pair
的间隔太大,这样的处理方式就会出现比较多的边界问题,举个比较极端的例子,假设我们最外层存在引用块,在引用块中又嵌套了表格,表格中又嵌套了高亮块,高亮块中又嵌套了引用块,这种情况下我们的
wrap
需要传递
N
多层才能匹配到
pair
,这种情况下影响最大的就是
Normalize
,我们需要有非常深层次的
DFS
处理才行,处理起来不仅需要耗费性能深度遍历,还容易由于处理不好造成很多问题。

那么在这种情况下,我们可以尽可能简化层级的嵌套,也就是说我们需要避免
wrap - pair
的间隔问题,那么很明显我们直接严格规定
wrap
的所有
children
必须是
pair
,在这种情况下我们做
Normalize
就简单了很多,只需要在
wrap
的情况下遍历其子节点以及在
pair
的情况下检查其父节点即可。当然这种方案也不是没有缺点,这让我们对于数据的操作精确性有着更严格的要求,因为在这里我们不会走默认行为,而是全部需要自己控制,特别是所有的嵌套关系以及边界都需要严格定义,这对编辑器行为的设计也有更高的要求。

{
    "quote-wrap": true,
    children: [
        {
            "list-wrap": true,
            "quote-pair": true,
            children: [
                { "list-pair": 1, children: [/* ... */] },
                { "list-pair": 2, children: [/* ... */] },
                { "list-pair": 3, children: [/* ... */] },
            ]
        },
        { "quote-pair": true,  children: [/* ... */] },
        { "quote-pair": true,  children: [/* ... */] },
        { "quote-pair": true,  children: [/* ... */] },
    ]
}

那么为什么说数据结构会变得复杂了起来,就以上述的结构为例,假如我们将
list-pair: 2
这个节点解除了
list-wrap
节点的嵌套结构,那么我们就需要将节点变为如下的类型,我们可以发现这里的结构差别会比较大,除了除了将
list-wrap
分割成了两份之外,我们还需要处理其他
list-pair
的有序列表索引值更新,这里要做的操作就比较多了,因此我们如果想实现比较通用的
Schema
就需要更多的设计和规范。

而在这里最容易忽略的一点是,我们需要为原本的
list-pair: 2
这个节点加入
"quote-pair": true
,因为此时该行变成了
quote-wrap
的子元素,总结起来也就是我们需要将原本在
list-wrap
的属性再复制一份给到
list-pair: 2
中来保持正确的嵌套结构。那么为什么不是借助
normalize
来被动添加而是要主动复制呢,原因很简单,如果是
quote-pair
的话还好,如果是被动处理则直接设置为
true
就可以了,但是如果是
list-pair
来实现的话,我们无法得知这个值的数据结构应该是什么样子的,这个实现则只能归于插件的
normalize
来实现了。

{
    "quote-wrap": true,
    children: [
        {
            "list-wrap": true,
            "quote-pair": true,
            children: [
                { "list-pair": 1, children: [/* ... */] },
            ]
        },
        { "quote-pair": true,  children: [/* ... */] },
        {
            "list-wrap": true,
            "quote-pair": true,
            children: [
                { "list-pair": 1, children: [/* ... */] },
            ]
        },
        { "quote-pair": true,  children: [/* ... */] },
        { "quote-pair": true,  children: [/* ... */] },
        { "quote-pair": true,  children: [/* ... */] },
    ]
}

Transformers

前边也提到了,在嵌套的数据结构中是存在默认行为的,而在之前由于一直遵守着默认行为所以并没有发现太多的数据处理方面的问题,然而当将数据结构改变之后,就发现了很多时候数据结构并不那么容易控制。先前在处理
SetBlock
的时候通常我都会通过
match
参数匹配
Block
类型的节点,因为在默认行为的情况下这个处理通常不会出什么问题。

然而在变更数据结构的过程中,处理
Normalize
的时候就出现了问题,在块元素的匹配上其表现与预期的并不一致,这样就导致其处理的数据一直无法正常处理,
Normalize
也就无法完成直至抛出异常。在这里主要是其迭代顺序与我预期的不一致造成的问题,例如在
DEMO
页上执行
[...Editor.nodes(editor, {at: [9, 1, 0] })]
,其返回的结果是由顶
Editor
至底
Node
,当然这里还会包括范围内的所有
Leaf
节点相当于是
Range

[]          Editor
[9]         Wrap
[9, 1]      List
[9, 1, 9]   Line
[9, 1, 0]   Text

实际上在这种情况下如果按照原本的
Path.equals(path, at)
是不会出现问题的,在这里就是之前太依赖其默认行为了,这也就导致了对于数据的精确性把控太差,我们对数据的处理应该是需要有可预期性的,而不是依赖默认行为。此外,
slate
的文档还是太过于简练了,很多细节都没有提及,在这种情况下还是需要去阅读源码才会对数据处理有更好的理解,例如在这里看源码让我了解到了每次做操作都会取
Range
所有符合条件的元素进行
match
,在一次调用中可能会发生多次
Op
调度。

此外,因为这次的处理主要是对于嵌套元素的支持,所以在这里还发现了
unwrapNodes
或者说相关数据处理的特性,当我调用
unwrapNodes
时仅
at
传入的值不一样,分别是
A-[3, 1, 0]

B-[3, 1, 0, 0]
,这里有一个关键点是在匹配的时候我们都是严格等于
[3, 1, 0]
,但是调用结果却是不一样的,在
A

[3, 1, 0]
所有元素都被
unwrap
了,而
B
中仅
[3, 1, 0, 0]

unwrap
了,在这里我们能够保证的是
match
结果是完全一致的,那么问题就出在了
at
上。此时如果不理解
slate
数据操作的模型的话,就必须要去看源码了,在读源码的时候我们可以发现其会存在
Range.intersection
帮我们缩小了范围,所以在这里
at
的值就会影响到最终的结果。

unwrapNodes(editor, { match: (_, p) => Path.equals(p, [3, 1, 0]), at: [3, 1, 0] }); // A
unwrapNodes(editor, { match: (_, p) => Path.equals(p, [3, 1, 0]), at: [3, 1, 0, 0] }); // B

上边这个问题也就意味着我们所有的数据都不应该乱传,我们应该非常明确地知道我们要操作的数据及其结构。其实前边还提到一个问题,就是多级嵌套的情况很难处理,这其中实际上涉及了一个编辑边界情况,使得数据的维护就变得复杂了起来。举个例子,加入此时我们有个表格嵌套了比较多的
Cell
,如果我们是多实例的
Cell
结构,此时我们筛选出
Editor
实例之后处理任何数据都不会影响其他的
Editor
实例,而如果我们此时是
JSON
嵌套表达的结构,我们就可能存在超过操作边界而影响到其他数据特别是父级数据结构的情况。所以我们对于边界条件的处理也必须要关注到,也就是前边提到的我们需要非常明确要处理的数据结构,明确划分操作节点与范围。

{
    children: [
        {
            BLOCK_EDGE: true, // 块结构边界
            children: [
                { children: [/* ... */] },
                { children: [/* ... */] },
            ]
        },
        {  children: [/* ... */] },
        {  children: [/* ... */] },
    ]
}

此外,在线上已有页面中调试代码可能是个难题,特别是在
editor
并没有暴露给
window
的情况下,想要直接获得编辑器实例则需要在本地复现线上环境,在这种情况下我们可以借助
React
会将
Fiber
实际写在
DOM
节点的特性,通过
DOM
节点直接取得
Editor
实例,不过原生的
slate
使用了大量的
WeakMap
来存储数据,在这种情况下暂时没有很好的解决办法,除非
editor
实际引用了此类对象或者拥有其实例,否则就只能通过
debug
打断点,然后将对象在调试的过程中暂储为全局变量使用了。

const el = document.querySelector(`[data-slate-editor="true"]`);
const key = Object.keys(el).find(it => it.startsWith("__react"));
const editor = el[key].child.memoizedProps.node;

最后

在这里我们聊到了
WrapNode
数据结构与操作变换,主要是对于嵌套类型的数据结构需要关注的内容,而实际上节点的类型还可以分为很多种,我们在大范围上可以有
BlockNode

TextBlockNode

TextNode
,在
BlockNode
中我们又可以划分出
BaseNode

WrapNode

PairNode

InlineBlockNode

VoidNode

InstanceNode
等,因此文中叙述的内容还是属于比较基本的,在
slate
中还有很多额外的概念和操作需要关注,例如
Range

Operation

Editor

Element

Path
等。那么在后边的文章中我们就主要聊一聊在
slate

Path
的表达,以及在
React
中是如何控制其内容表达与正确维护
Path
路径与
Element
内容渲染的。


Reviewbot
是七牛云开源的一个项目,旨在提供一个自托管的代码审查服务, 方便做 code review/静态检查, 以及自定义工程规范的落地。


在日常的编程协作中,Git commit 记录的质量往往反映了一个工程师的工程素养。然而,我经常能看到一些不太规范的 commit 记录。有时,真的不敢恭维。

比如这种:

这种大概率是提交 commit 之后,又有变动,就随手重新复用上一条 git commit 命令了。

这种记录如果出现在个人仓库,可能还好. 但如果是多人协作的仓库,就有点不专业了。

在我看来,这些 commit 记录完全没必要,是非常不好的习惯,完全可以避免。

好在 Git 为我们提供了优雅的解决方案。如果没必要生成新的 commit,那直接使用
git commit --amend
就可以避免。

少用
git merge
多用
git rebase

比如这种:

Merge branch 'feature-A' of https://github.com/qiniu/reviewbot into feature-B

说的是把远程分支 feature-A 的代码合并到 feature-B 里。这里的 feature-A 通常是主分支。

这种 Commit 信息如果出现在你的 PR 里,那是完全没必要。PR 里的 commit 信息应当仅包含针对本次改动的有用信息。

我个人日常几乎不使用
git merge
,即使是为了同步远程分支,我一般都会使用
git rebase

比如:

git rebase 除了上述好处外,还可以保持主仓库的 commit history 非常干净。所以强烈推荐大家使用。

Reviewbot 的 git commit check

为了更好的规范上述两种行为,Reviewbot 也添加了 git commit check 能力,就是用来检查 git commit 记录是否符合规范的。

如果不符合规范,Reviewbot 就会提示你:

更多 git flow 使用规范和技巧

当然 git 操作其实有很多实用技巧,建议大家有兴趣的话可以去研究下。我在 1024 实训营的时候,有给同学们做个相关分享:

超实用! 从使用视角的 Git 协作实战,告别死记硬背

文档里面有视频链接,感兴趣的同学可以去看下。

最后,作为专业的工程师,我们应该始终追求卓越的工程实践。良好的 commit 记录不仅体现了个人的专业素养,更是提升团队协作效率的重要基石。

通过合理使用 git rebase 和 git commit --amend,我们可以维护一个更清晰、更专业的代码提交历史。这不仅让代码审查变得更加轻松,也为后续的代码维护和问题追踪带来极大便利。

你觉得呢?

在上一篇文章中,我们介绍了Wgpu中的渲染管线与着色器的概念以及基本用法。相信读者还记得,我们在渲染一个三角形的时候,使用了三角形的三个
顶点的索引
作为了顶点着色器的输入,并根据索引值计算了三个几何顶点在视口中的位置,并通过片元着色器的代码逻辑,控制了每一个像素都用红色色值,最终渲染了一个红色三角形:

010

当然,我们不可能一直使用wgpu来渲染这样的简单固定的图形。面对实际的场景,我们有时候需要根据一些上下文来动态的修改渲染图形的大小形状。在本文中,我们将开始介绍顶点缓冲区的概念,来为后续实际的场景做一些铺垫。

认识缓冲区

缓冲区
(Buffer)一个可用于 GPU 操作的内存块(又叫“显存”)。在wgpu(或其他例如OpenGL等库)中的缓冲区概念通常指的是 GPU 能读写的内存区域,与之对应的就是我们常见的CPU内存。回想一下常规的软件运行的过程:程序在启动后,会在“内存”中申请一块能够存放数据的区域。在运行的过程中,我们的代码指令按照既定的逻辑做着计算,并不断的读、写内存区域里面的数据,以达到期望的程序运行的结果。
不严谨地讲
,GPU 与 CPU 是一样的,它同样能够执行计算逻辑,同样会有数据存储的区域,这个区域就是 GPU 的缓冲区。

一般来说,我们都在 CPU 直接阶段,在内存中将一些初始的数据准备好,通过一定的方式发送给 GPU,并存储在GPU上的缓冲区中。在执行的过程中,我们可以通过着色器代码来读取缓冲区中的数据:

020

创建顶点缓冲区

为了更好的管理不同类型的数据(比如常见的有顶点数据、顶点索引数据),我们会按照其不同类型来设定不同的缓冲区。在本文中,我们先介绍如何创建并使用顶点缓冲区,对于其他缓冲区我们会在后续的文章中说明。

顶点缓冲区,顾名思义,就是包含了在渲染过程中会使用到的
顶点数据
的 GPU 显存区域。需要注意的是,图形学中的顶点并不是我们常规意义上的几何顶点,而是包含了位置坐标、颜色信息、纹理坐标以及法线向量等的顶点数据,常规意义上的几何顶点
仅仅
是顶点数据中的一部分。

在上一篇文章中,尽管在最后我们成功终绘制了一个三角形,但实际上它的三个顶点位置是通过三个顶点索引(0、1、2)计算而来的。假设我期望绘制一个比较另类的三角形或其他图形,纯粹靠顶点索引是不够的。这种场景我们一般会按照如下的方式进行:

  1. 准备一些包含自定义位置信息的顶点数据;
  2. 将顶点数据放置到顶点缓冲区中,并进行一定的配置;
  3. 最后,在着色器代码中通过一定的方式读取这些顶点数据,并交给顶点着色器来使用。

接下来让我们开始实践如何通过编程方式创建顶点缓冲区。

假设最终我们期望渲染一个由
(0, 1)

(-0.5, -0.5)

(0.5, 0)
三个2维顶点构成的三角形:

030

首先,让我们在基础项目中增加一个结构体Vertex,用来表达我们的顶点:

040

这个结构体我们现在仅有一个类型为
[f32; 3]
类型的字段
position
,用来表示一个位置坐标。

⚠️这里务必添加Copy派生

引申:关于内存布局

该结构体上的属性,除了我们常见用来派生
Copy

Clone
等trait的
derive
属性外,还有一个特殊的属性:
#[repr(C)]
,在配置该属性后,Rust 编译器会强制
按照 C 编译器
的编译方式来安排结构体字段的顺序和
对齐方式
。假设有如下结构体,在
#[repr(C)]
的加持下,其内存布局会保持4字节对齐:

050

上面的结构体中,age字段的类型是u8,但因为强制使用了
#[repr(C)]
,让其保持了4字节的内存布局。我们可以用如下的代码来验证:

060

当然,有的小伙伴会发现即使不添加
#[repr(C)]
,结果也是24bytes,是因为Rust编译器在某些场景下会进行对齐,
不过这样无法保证是按照和C编译器一样的4字节对齐
;此外,Rust编译器在有时为了内存的高效利用,可能会进行布局压缩。当然,你还可以使用
#[repr(packed)]
来禁用内存对齐填充:

070

好了,让我们回归正文。此时我们已经编写了一个
Vertex
结构体,也理解了
#[repr(C)]
的意义。接下来,我们创建一个数组切片来存放三个顶点的数据:

// 表示三角形三个顶点的顶点列表
pub const VERTEX_LIST: &[Vertex] = &[
    Vertex { position: [0.0, 1.0, 0.0] },
    Vertex { position: [-0.5, -0.5, 0.0] },
    Vertex { position: [0.5, 0.0, 0.0] },
];

在编写了三个顶点的数据后,我们更进一步,将顶点缓冲区创建出来消费顶点数据。

首先,让我们在
async_new
方法中的合适位置通过调用Device实例的
create_buffer_init
方法创建一个顶点缓冲区对象:

080

contents
字段需要我们提供
&[u8]
类型的数据,即字节数组的切片引用,这里我们先传空,待会儿会讲到如何将我们的
VERTEX_LIST
数据转为
&[u8]
类型的数据;
usage
字段我们现在传入
wgpu::BufferUsages::VERTEX
这个枚举,表明我们要创建的是一个顶点缓冲区,而不是其他的缓冲区。

接下来,我们尝试将前面创建的
VERTEX_LIST
数据转换为
&[u8]
字节数据。这里我们使用一个工具库
bytemuck
,该库可以方便的将我们的一些数据结构转为内存中的字节数据。其具体方式如下:

  1. 在依赖中添加
    bytemuck
  2. 修改
    Vertex
    结构体的内容:

090

  1. 在创建缓冲区的地方添加如下的转换代码:

100

调用bytemuck的cast_slice方法,将原始数据转为u8的切片,并作为contents字段的值传入

  1. 修改
    WgpuCtx
    结构体,保存我们本次创建的顶点缓冲区实例:

110

总结一下,为了创建一个顶点缓冲区,我们经历如下几步:

  1. 定义一个结构体(
    Vertex
    )来表示一个顶点数据,该结构体除开配置
    #[derive(Copy, Clone)]
    属性外,还需要使用
    #[repr(C)]
    来保证该结构体在编译后的内存布局及对齐字节数据保持和C编译器一样;以及,让结构体实现来自
    bytemuck
    库提供的
    Pod

    Zeroable
    两个trait,以供后续通过
    bytemuck
    的提供的API来将数据转为
    &[u8]
  2. 完成
    Vertex
    结构体的定义后,我们又根据最终想要渲染的三角形的几何结构,使用
    VERTEX_LIST
    来存储了三个顶点数据。
  3. 使用
    bytemuck
    提供的API将
    VERTEX_LIST
    通过将其转为了u8字节数组切片字节数据。
  4. 调用Device提供的API
    create_buffer_init
    ,传入顶点数组字节数据,以创建一个顶点缓冲区实例。
  5. 将顶点缓冲区实例存储到WgpuCtx实例,以供后续消费使用。

至此,对于创建顶点缓冲区部分的介绍就到此为止。接下来我们需要介绍另一个同样重要的内容:
顶点缓冲区布局(VertexBufferLayout)

创建顶点缓冲区布局

首先,我们需要明白为什么会有
缓冲区布局
这一东西。假设现在在 GPU 显存中有如下的一段数据:

120

在没有其他上下文的情况下,我们无法理解这段内存中的数据有何意义。同样的,如果我们单是把先前创建的顶点数据放入顶点缓冲区中,在实际渲染的过程中,GPU 也无法理解这一堆的二进制数据应该如何使用。此时,我们就需要用一些配置上下文来解释顶点缓冲区中的数据的具体意义。

还是以上图数据为例,如果现在告诉你这是一段包含了
3
个顶点数据的内存布局,其步进(stride)是3字节(即每三个字节就算做一个顶点数据);同时,单看每一份顶点数据,按照从其
偏移字节为0
的地方开始是一份位置数据,其类型为3个float32(32bits,即4bytes)数据,现在对于这段内存中的数据的布局结构是不是变的比较清晰了呢:

130

有了上述的说明,再回过头来就不难理解缓冲区布局的意义了。接下来就让我们通过代码实践来定义一个顶点缓冲区布局实例。

首先,我们依然在
vertex.rs
文件中增加一个方法,用来返回一个顶点缓冲区布局实例:

140

对于该方法的实现,我们就是返回了如下的一个结构体:

wgpu::VertexBufferLayout {
    array_stride: size_of::<Vertex>() as wgpu::BufferAddress,
    step_mode: wgpu::VertexStepMode::Vertex,
    attributes: &[
        wgpu::VertexAttribute {
            offset: 0,
            shader_location: 0,
            format: wgpu::VertexFormat::Float32x3,
        },
    ],
}
  • 字段
    array_stride
    表示的就是每一份顶点数据在内存中的
    步进
    长度,在本例中,一个
    Vertex
    结构体在
    #[repr(C)]
    的属性配置下能够确保是12bytes。

  • 字段
    step_mode
    我们暂时不详细介绍,读者可以简单理解为告诉渲染管线每一份数据代表的是一个顶点数据(),这里默认使用该枚举值
    VertexStepMode::Vertex
    即可。

  • 字段
    attributes
    是一个数组切片引用,在这里我们只传递了一个
    VertexAttribute
    数据,表示就目前而言,我们一份顶点数据中,只有一份有意义的“子数据”。对于这份“子数据”,我们配置了
    offset

    shader_location
    以及
    format
    字段。这三个字段整体表达了这样一个事实:在一份顶点数据中,从
    offset = 0
    开始有一段格式为
    Float32x3
    (float32 = 32bits = 4bytes, 乘以3就等于12bytes)的数据,这段数据在shader着色器上的location为0的位置。相信读者对offset和format应该能够理解,但是对于“这段数据在shader着色器上的location为0的位置”这句话还有些难以理解,别着急,我们后面会讲到的。

消费缓冲区及布局

总结下现状,我们首先创建了顶点缓冲区并将其作为
vertex_buffer
存放到了
WgpuCtx
实例中;同时,我们还编写一个名为
create_vertex_buffer_layout
的方法用来构造一个顶点缓冲区布局实例,接下来我们会使用到上面准备工作的成果了。

首先,让我们在
WgpuCtx

draw
方法中适当修改代码来消费顶点缓冲区:

150

在调用渲染通道(RenderPass)实例的
draw
方法前,我们先调用
set_vertex_buffer
方法。该方法接受两个参数,第一个参数slot指的是我们要把顶点缓冲区中的数据放置到显存内部的顶点缓冲区域的哪个索引位置,这里我们设置为0,表示我们会设置到默认0的位置;第二个参数使用的缓冲区的数据片段,这里我们直接消费整个顶点数据,因此代码编写为
slice(..)

然后,修改
draw
的参数传递。将原来固定的
0..3
(即3个顶点)修改为动态的,根据我们创建的
VERTEX_LIST
的实际长度,这样在将来我们会创建更多的顶点的时候,就能够正确对应顶点数量。

完成消费顶点缓冲区的代码编写以后,接下来我们就需要再适当的位置创建缓冲区布局实例并消费它,其具体做法是:

调用
create_vertex_buffer_layout
方法得到缓冲区布局实例对象;把该实例对象传递给如下
VertexState

buffers
字段:

160

这个地方叫做buffers,但是实际上是要传buffer布局,maybe命名有点让人误导。

到目前为止内容偏多,让我们通过下图做一个简单的总结:

170

修改顶点着色器程序

上面的实践过程,我们仅仅是创建并消费了顶点缓冲区以及顶点缓冲区布局实例。然而,如果在此时运行程序代码,读者会发现窗口中依然是先前的一个撑满窗口的红色三角形。很显然,我们需要适当的修改着色器程序的代码,才能真正消费到我们在上面产生的有关顶点数据。让我们对
shader.wgsl
做出如下的修改:

180

首先,我们在着色器代码中定义了一个结构体
VertexInput
,这个结构体包含有一个
position
字段,其类型为
vec3f

值得注意的是,这个字段有一个前置的注解
@location(0)
。还记得前面我们说过:“这段数据在shader着色器上的location为0的位置”这句话吗?其实这里的
location(0)
对应匹配的就是前面在定义顶点缓冲区布局的
shader_location
配置:

190

对于顶点数据、顶点缓冲区布局配置以及着色器中
VertexInput
的结构定义,我们就可以用下图来解释它们的关系了:

200

再看顶点着色器
vs_main
的部分,其入参由原来的
@builtin(vertex_index) in_vertex_index: u32
修改为了
vertex_in: VertexInput
。在每次顶点着色器运行的时候,渲染管线会结合顶点缓冲区布局配置以及每一份内存中的顶点数据,为我们构建一个
VertexInput
结构体实例,并传入该顶点着色器方法中。在这里我们就可以直接读取到对应的position位置字段数据并直接返回了。

而对于片元着色器,我们暂时没有任何改动。因此,在一切准备工作结束以后,让我们运行程序,会发现最终渲染的三角形确实如我们所期望的结构那样展示了:

210

给顶点数据加入更多的信息

在本文中,由于我们的顶点数据结构体
Vertex
中只包含了一个类型为
[f32; 3]
的位置数据字段,因此在设置
VertexBufferLayout

attributes
字段的时候,我们只传入了一个
VertexAttribute
配置,并且其offset为0,代表了我们的一份在内存中的顶点数据,只包含一份属性数据,且是从偏移字节为0开始的。当然,正如前面提到的,顶点数据并非只会有位置数据,通常伴随着的还会有颜色信息、法线信息等。在这里,我们尝试给顶点加入颜色数据,好在着色器处理阶段能够定制三角形的颜色。

首先,让我们尝试修改
Vertex
结构体,加入一个颜色字段:

220

完成以后,可以想象到,把
VERTEX_LIST
数据转为字节数据放到顶点缓冲区以后,其内存布局会是如下形式:

230

如果读者理解了前面提到的offset的含义,那么就不难想到,为了让顶点着色器能够访问到颜色信息。我们需要将顶点缓冲区布局中关于attributes字段增加一条配置:

240

  1. 在顶点缓冲区布局对象的
    attributes
    字段,在原有基础上,再插入一份
    VertexAttribute
    配置,代表了要配置颜色信息;
  2. 对于新加的
    VertexAttribute
    ,其
    offset
    字段填入的值是偏移过position字节数据长度;
  3. 将颜色信息数据指定为着色器中的location为1的地方。

接下来,我们只需要修改着色器代码
VertexInput
结构体,增加一个color字段,

250

此时,渲染管线在构造这个
VertexInput
实例的时候,就能知道除了原有position字段数据外,还会把显存中的一份顶点数据的后面float32x3的大小数据映射到
@location(1) color: vec3f
上了。

当然,仅仅给
VertexInput
增加color字段,对于我们最终的渲染效果目前来说是没有任何影响的,因为我们压根儿没有消费这个color字段。为了消费这个字段,并让最终渲染的三角形的颜色产生变化,接下来就让我们关注一下着色器代码,看看还需要做什么。

首先,我们之前讲到过,对于顶点着色器的方法,我们返回的是
@builtin(position) vec4<f32>
,这意味着每次顶点着色器运行以后,会得到一个顶点的位置数据。在本例中,在所有顶点都执行以后,我们会得到3个顶点位置,渲染管线会拿着这3个位置构建一个三角形,并进行栅格化,再调用片元着色器,然后我们会再片元着色器中为每一个像素指定颜色。那么这里有一个问题:我们只能够在
顶点
着色器中返回每个顶点的位置吗?答案当然是否定的。除了直接返回一个
@builtin(position)
修饰的数据类型,我们还可以返回一个结构体,只要这个结构体中有一个字段用
@bultiin(position)
修饰即可:

260

这段修改后的代码的效果其实和之前是一样的,只不过我们用过了结构体来包裹。在返回结构体的形式下,我们可以在结构体中加入一些其他的字段,并且,在片元着色器节点还可以访问到顶点着色器输入结构体数据:

270

上述的着色器代码编写完成以后,理论上运行程序,你会发现如下的效果:

280

wow,一个
渐变
的三角形!然而,这就结束了吗?非也!

如何得到颜色

⚠️笔者水平有限,因此后面的内容笔者仅能靠自己目前浅显的理解进行总结,其中可能会存在一些不到位或不正确的理解,这里恳请相关专业人士对错误的内容批评指出。

如果读者认真看到现在,并且仔细思考了以后,我相信你会有一些疑问。首先,目前我们只有3个顶点,那么顶点着色器理论上来讲只会被调用3次,也就是说,我们总共只会得到3个
VertexOutput
数据并返回给渲染管线,且这3个
VertexOutput
实例的color字段分别只会是
(1.0, 0.0, 0.0)
(红色)、
(0.0, 1.0, 0.0)
(绿色)以及
(0.0, 0.0, 1.0)
(蓝色)。然而,我们最终渲染的三角形是一个
渐变
三角形。根据片元着色器的作用,它会在每一个像素处理阶段被调用,这是否能够表明一件事:片元着色器代码中的消费的
color
字段,和前面的
VertexOutput

color
其实不是一个东西?答案确实如此。

290

细心的读者会发现,在
fs_main
入参,尽管类型是
VertexOutput
,但我刻意的避免了使用
vertex_output
作为名称,而是使用了
data
,其实就在暗示从顶点着色器
vs_main
返回的
VertexOutput
跟这里片元着色器
fs_main
得到的输入
VertexOutput
实例并不是一个东西。让我们开拓下思维,结构体的本质是什么?
实际上,结构体只是一种对内存数据的具名表达而已
,在这里我们仅仅通过了
VertexOutput
这个具名的描述内存数据形态的标识作为了顶点着色器和片元着色器的桥梁而已。

300

换句话说,如果我们改成下面的代码,我们的程序同样能够正确的运行:

310

那顶点着色器输出的位置和颜色信息,最终是如何影响到片元着色器的输入的呢?对于渲染管线来说,在顶点着色器执行以后,它会得到三个顶点数据,而其中就有通过
@builtin(position)
标识的,能够表达位置信息的数据。很显然,有了三个点的位置信息,在图元装配结合光栅化以后,我们能够得到一个最终图形的上的任意一个像素点位置信息:

320

每一个顶点中我们都增加了一份数据用来表示颜色(color字段)。那么,对于三角面中任意一个像素的点位置的color字段数据,实际上是三个顶点颜色数据在
此位置
上的算法叠加:

330

即,我们可以用一个方法来表达三角面上任意一个点的颜色:

fn color((v1_pos, v1_color), (v2_pos, v2_color), (v3_pos, v3_color), any_pos) -> color

通过输入三个顶点的位置和颜色,以及任何一个三角面上的点位置,就能算出该点的颜色数据。但值得注意的是,在我们的场景中,我们对
@location(0)
位置的数据取名为了color,表明用该字段作为颜色字段,但是在内存中,管线只知道这里有一份类型为
vec3f
的数据罢了。所以,对应的更加通用的公式应该是:

fn get_data((v1_pos, v1_data), (v2_pos, v2_data), (v3_pos, v3_data), any_input_pos) -> data_in_pos

那么关于这个的具体实现在本文中不再细讲,但最简单的方式应该就是线性叠加,读者可以自行深入这块的内容。

关于@location

另外我们还需要着色器代码解释另一个东西。仔细观察代码,无论是
VertexOutput
还是
FragmentInput
结构体,我们都在
color
这个字段使用了注解
@location(0)
。在前面的
VertexInput
结构体的中的
color
字段我们使用了同样的注解,其含义是我们把一份内存中对应位置的数据设置为了着色器中一个结构体中
location = 0
的位置的字段。那么这里是不是也是同样的意义呢?答案确实是如此的。

读者可以这样理解,在光栅化后,每一次片元着色器的输入也是一份内存数据,这份内存数据我们同样可以使用一个结构体来访问(因为结构体是内存中的数据的可读性表达),但是结构体中的字段可以有很多个,每个字段究竟是内存中哪一块的数据,需要有一个明确的指明:

340

在上图中,无论是
VertexOutput
结构体还是
FragmentInput
结构体,其内存的布局是一致的,因此在片元着色器执行的时候,渲染管线提供的数据我们用上述两种结构体够可以表达对应的内存数据。再想的远一点,这个
location
只能是0吗?其实也不是,因为本质上讲,它是一段数据的标识,只是本例中,我们使用了0这个位置标识而已,如果你乐意,你还可以编写如下标识:

350

甚至,你还可以不编写任何的结构体作为输入,而是直接使用
@location
来定位:

360

对于最后的一种使用方式,请读者自己揣摩一下~

写在最后

本文的内容较多引申了不少额外的内容,读者可以慢慢阅读消化,希望能够对认识wgpu以及图形学工程有更进一步的理解和认识。在接下来的内容,我们将会认识wgpu中有关于以及图形学工程相关的更多的内容,敬请期待!

本章的代码仓库在这里:

https://github.com/w4ngzhen/wgpu_winit_example/tree/main/ch03_buffer

后续文章的相关代码也会在该仓库中添加,所以感兴趣的读者可以点个star,谢谢你们的支持!

给网站免费升级HTTPS协议,可以通过申请并部署免费的SSL证书来实现。以下是一个详细的步骤指南:

一、申请免费SSL证书
选择证书颁发机构:
可以选择像JoySSL这样的公益项目,它提供免费、自动化的SSL/TLS证书颁发服务,适用于各种规模的网站。

免费SSL证书申请入口
提交申请:
登录所选证书颁发机构的官方网站,并创建一个账号,注:在注册的过程中需要填写注册码
230922
来获取免费证书申请权限。
根据需求选择合适的SSL证书类型,如单域名证书、多域名证书或通配符证书。
提交申请,并验证域名的所有权。这通常涉及DNS记录验证、文件验证或邮箱验证等方式。
下载证书:
验证通过后,证书颁发机构会签发证书。
下载收到的SSL证书文件,并解压备用。

二、部署SSL证书
登录服务器:
登录到托管网站的服务器。
上传证书:
将下载的SSL证书文件上传到服务器。
配置Web服务器:
根据所使用的Web服务器(如Apache、Nginx或IIS),修改相应的配置文件。
设置SSL模块和证书路径。
启用HTTPS监听端口(默认为443)。
重定向HTTP请求:
在Web服务器配置中设置规则,将所有HTTP请求自动重定向到对应的HTTPS URL。

三、更新网站链接
检查并更新内部链接:
确保网站上的所有内部链接(包括页面间的链接、CSS、JavaScript、图片等)都使用HTTPS协议。
如果存在混合内容(即页面通过HTTPS加载,但包含HTTP资源引用),浏览器可能会显示警告,影响用户体验和安全性。
使用开发者工具检查:
可以使用浏览器的开发者工具来检查并修正这些问题。

四、其他注意事项
备份网站数据:
在进行任何更改之前,备份网站数据以防万一。
定期更新证书:
免费SSL证书通常有一定的有效期。在证书到期之前,需要重新申请并部署新的证书。
监控和维护:
定期监控SSL证书的有效期和安全性。
使用在线SSL测试工具检查配置是否正确。

通过以上步骤,即可将网站从HTTP免费升级为HTTPS,从而享受加密通信带来的数据安全和用户信任提升。

来源:晓飞的算法工程笔记 公众号,转载请注明出处

论文: CNN Mixture-of-Depths

创新点


  • 提出新的卷积轻量化结构
    MoD
    ,在卷积块(
    Conv-Blocks
    )内通过动态选择特征图中的关键通道进行集中处理,提高效率。
  • CNN MoD
    保留了静态计算图,这提高了训练和推理的时间效率,而且不需要定制的
    CUDA
    内核、额外的损失函数或微调。
  • 通过将
    MoD
    与标准卷积交替使用,能够实现相等性能下的推理加速或相等推理速度下的性能提高。

CNN Mixture-of-Depths


MoD
由三个主要组件组成:

  1. 通道选择器:根据输入特征图与当前预测的相关性选择前
    \(k\)
    个最重要的通道。
  2. 卷积快:从现有架构(如
    ResNets

    ConvNext
    )中进行改编,旨在增强选定通道的特征。
  3. 融合算子:将处理后的通道加到特征图的前
    \(k\)
    个通道上。

通道选择器

通道选择器主要分为两个阶段:

  1. 自适应通道重要性计算:通过自适应平均池化压缩输入特征图,随后通过一个具有瓶颈设计的两层全连接网络进行处理,设定
    \(r = 16\)
    ,最后通过
    sigmoid
    激活函数生成一个分数向量
    \(\mathbf{s} \in \mathbb{R}^C\)
    ,量化了相应通道的重要性。
  2. Top-k
    通道选择与路由:利用重要性分数
    \(\mathbf{s}\)
    选择前
    \(k\)
    个通道输入卷积块处理,原始特征图
    \(X\)
    则直接传递融合算子。

这个选择过程使得通道选择器能够高效地管理计算资源,同时保持固定的计算图,从而实现动态选择要处理的通道。

动态通道处理

每个卷积块中处理的通道数量
\(k\)
由公式
\(k = \lfloor \frac{C}{c} \rfloor\)
决定,其中
\(C\)
表示该块的总输入通道数,
\(c\)
是一个超参数,用于确定通道减少的程度。例如在一个标准的
ResNet
瓶颈块中,通常处理
1024
个通道,设置
\(c = 64\)
会将处理减少到仅
16
个通道(
\(k = 16\)
)。

通过实验发现,超参数
\(c\)
应设置为第一卷积块中输入通道的最大数量,并在整个
CNN
中的每个
MoD
块中保持相同。例如,
ResNet

\(c = 64\)
MobileNetV2

\(c = 16\)

卷积块的最后一步涉及将处理后的通道与从自适应通道重要性计算中获得的重要性评分相乘,确保在训练过程中梯度能够有效地传递回通道选择器,这是优化选择机制所必需的。

融合机制

将处理后的特征添加到
\(X\)
的前
\(k\)
个通道中,保留其余未处理的通道。融合后的特征图
\(\bar{X}\)
具有与原始输入
\(X\)
相同的通道数
\(C\)
,从而保留了后续层所需的维度。

论文在实验中测试了多种将处理后的通道重新集成到特征图
\(X\)
中的策略,包括将处理后的通道添加回其原始位置,但结果并未显示任何改进。实验表明,始终在特征图中使用相同位置来处理信息似乎是有益的,将处理后的通道添加到后
\(k\)
个通道中得到了与添加到前
\(k\)
个通道时相当的结果。

集成到
CNN
结构

MoD
可以集成到各种
CNN
架构中,例如
ResNets

ConvNext

VGG

MobileNetV2,
这些架构被组织成包含多个相同类型(即输出通道数相同)的卷积块(
Conv-Blocks
)的模块。

实验表明,交替使用
MoD
块和标准卷积块在每个模块中是一种最有效的集成方法。需要注意的是,
MoD
块替换每第二个卷积块,从而保持原始架构的深度(例如,
ResNet50
中的
50
层)。每个模块以一个标准块开始,例如
BasicBlock
,然后是一个
MoD
块。

这种交替模式表明,网络能够处理显著的容量减少,只要定期进行全容量卷积。此外,该方法确保
MoD
块不会干扰通常发生在每个模块的第一个块中的空间维度缩减卷积。

主要实验




如果本文对你有帮助,麻烦点个赞或在看呗~
更多内容请关注 微信公众号【晓飞的算法工程笔记】

work-life balance.