Armory3D: RenderTarget & Canvas UI

## 🐥 RenderTarget & Canvas UI
Armory Engine 逻辑节点编程中 Graphics 处理的节点有四类:
- [Draw] 对画布绘制简易图形、图像纹理、曲线等等,配合 `On Render2D`、Canvas UI Trait 使用;
- [Canvas] 节点分类下用于 Armory2D 设计好的 Canvas UI 元素的交互,比如设置或获取文本;
- [Post-Process] 后期处理添加图像处理效果,比如 LUT 纹理,一方面提升画面质量,同时又减少渲染运算;
- [Render Path] 渲染路径,对游戏渲染引擎进行编程,定制渲染过程。
文档来源 https://github.com/Jeangowhy/opendocs/blob/main/Haxe.md
[LUT Textures] 是一种基于查表算法的图像后期处理技术,LUT 纹理图像就是一张颜色对照表,LUT 意思是对纹理图像进行查表,Lookup Table Texture,找到指定的色彩替换到原图像上形成新的图像输出。查表是一种映射算法,比起引擎的渲染,效率极高。同时预配置不同的色彩卡可以得到不同的图像效果输出,操作流程可标准化,使用十分方便。参考官方的示例 [render_colorgrading]。
配色图片的本质就是将颜色方块进行二维化处理。假设 LUT 纹理图片分辨率为 512 * 512,划分为 8 * 8 的大格子,每个大格子中存有 64 * 64 个小格子,即用来存储色彩像素点。每个小格子 X 轴表示 R 通道 Y 轴表示 G 通道,取值范围 [0, 255]。剩下蓝色分量的 B 通道放在了大格子中,从左到右,从上到下,最后将 RGB 三个分量叠加。一张颜色图片一功能储存 64 * 64 * 64 = 512 * 512 = 262144 种色彩。例如,一张灰度配色图:

以下示范使用逻辑节点创建进度条效果,添加一个 Canvas UI,只需要一个 CProgress_bar 进度条控件。然后在 Armory Scene Traits 添加一个逻辑节点树,节点连接如下:
1. Event - `On Update` 事件流向 `Set Variable`,再流向 `Set Canvas Progress Bar`;
2. Variable - `Integer` 使用整数变量记录进度值;
3. 将整型变量节点连接到赋值节点的 Variable 端口,将其值经过 `Math` 加法运算后连接到赋值节点;
4. 将整型变量连接到 `Set Canvas Progress Bar` 控制进度条的显示;
进度条设置的 At 值如果超出 Max 范围,则会折回再开始绘制进度,相当于从零开始。

使用 Draw 分组下的逻辑节点,使用的是 Kha Graphics2 API,主要是 `GraphicsExtension` 和 `Graphics`。需要在 `On Render2D` 回调中使用,以确保它在画 Canvas 上下文,也就是需要在Armory Traits 列表中添加 Canvas Trait 扩展使用,否则不会触发 `On Render2D` 事件:
DrawRectNode must be executed inside of a render2D callback.
If used in logic node, please consult its documentation.
另外,还要将 Canvas UI 扩展挂载到场景,或者活动相机上,当然挂载到对象 Armory Trait 扩展列表也可以显示 UI 界面元素,但是逻辑节点的 UI 绘画相关操作不能使用它,并且引发异常。任何不配对的画布上下文 `begin()` 和 `end()` 方法将导 kha.graphics4.Graphics2 触发致异常:
Draw Image 可以将图像纹理绘制到 Canvas 2D 画布上,注意设置图像路径,默认使用 Bundled 目录存放资源文件,因为打包会碾平目录结构,所以不要使用子目录。Render - Armory Project 属性面板设置了一个 Copy to Bundled 按钮,它可以将所有外部或其它目录中的资源复制到 Bundled 目录。路径指定时就填入文件名,或者 //filename.jpg 这样的路径。
另一种方法是直接向 khafile.js 添加脚本配置,Armory Project - Modules - Append Khafile 指定一个脚本文件,Blender 文本编辑器可以创建脚本文件:
project.addAssets("textures/**", { notinlist: true });
由于 Zui 没有布局容器概念,所有 Canvas UI 元素使用 XY 坐标加 Anchor 来确定如何放置。锚点属性有两个维度,水平和竖直方向。对应 anchorH 和 anchorV 属性。所以注意设置好大小尺寸,配置锚点定位。注意,旋转角度 Angle 使用的是弧度,不是 Degree。
@input Left/Center/Right: Horizontal anchor point
0 = Left, 1 = Center, 2 = Right
@input Top/Middle/Bottom: Vertical anchor point
0 = Top, 1 = Middle, 2 = Bottom
渲染目标 **Render Target** 是一种可以在运行时写入数据的纹理。从引擎的角度讲,渲染目标存储颜色、法线以及 AO 等信息。从用户的角度讲,渲染目标可以视为第二个摄像机,用于捕捉图像并保存在内存中,并将其设置为指定对象材质的参数。从文件存储关系上讲,渲染目标是内存中的图像,纹理文件是硬盘中的图像。在不同平台下,`RenderTarget` 接口会使用不同的对象类型实现,比如 HTML5 对应:
armsdk\Kha\Backends\HTML5-Worker\kha\Image.hx
以下是与渲染目标相关的逻辑节点:
1. `Draw Camera` 从指定摄影机的视图中渲染场景,并将渲染目标纹理按指定大小绘制到屏幕指定坐标上。
2. `Draw Camera to Texture` 将指定相机视图的纹理图像绘制到指定对象的指定材质的纹理上。
3. `Draw to Material Image` 用来绘画 `Create Render Target Node` 创建的 render target;
`Draw Camera to Texture` 节点代码中包含了自动创建渲染目标的功能,只需要为节点指定相机对象,以及待绘制的目标对象,Object 属性可以留空,表示使用 owner 对象。节点一旦运行后,就会注册一个渲染回调函数,所以不能持续触发 Start 控制流。渲染回调函数对 Render Target 对象进行绘画,绘画内容将取代对象材质中的第一个上下文的第一个纹理,diffuse texture 或者 base color texture。
参考官方示范 render_to_texture,其代码和节点代码相比,多了一个切换相机 renderTarget 的步骤。示范代码中则是一个相机镜头始终与一个新创建的 RenderTarget 对象绑定。
如果轮番切换渲染对象,在游戏开始运行时,首次渲染回调中可能获取到的是一个 null,也就还未为相机设置渲染目标,需要设置渲染目标后,才能调用 `renderFrame()` 方法时得到纹理图像。如果场景中只有一个相机,那么固定渲染目标后,内容始终不变,就像画面卡住一样。并且,可能由于一开始就是没任何内容,导致相机渲染的图像始终全黑。
另外,World 材质属性设置会在编译时缓冲到环境贴图 env_World.jpg,例如使用纯色作为环境背景,Background 节点的 Strength 调整对 Armory 无效,除非 `Armory Project -> Clean` 清理项目缓存后,再重新编译。
如果没有绘制任务内容到 RenderTarget,那么将它赋予对象材质后,结果就会导致绘画纹理全黑。从相机视角形成的图像,再绘制到 RenderTarget,再通过指定的材质纹理重现出来,这至少涉及两次绘图,两绘制图像的尺寸比例不一致就会引起图像的变形。另外,相机拍摄得到的是 2D 纹理,重现到模型上又经过 UV Map 的映射,这又是一个图像变换过程。并且,在持续的渲染中,相机拍摄到的新内容不断叠加,这个复杂的过程会形成图像的递归渲染,最终效果不一定是相像中的一样。
armory_examples-22.06\render_to_texture\Sources\arm\MyTrait.hx
Iron 框架定义的 SceneFormat 结构中,一个材质对象 `MaterialData` 包含多个材质上下文对象和一个着色器数据对象,这样的类型层次结构设计都是为了着色器编程定制的:
1. `MaterialContext` 材质上下文对象包装纹理数据;
2. `ShaderContext` 着色器上下文对象包装着色器程序中需要引用的数据,以及符号定义;
iron\Sources\iron\object\MeshObject.hx
iron\Sources\iron\data\SceneFormat.hx
iron\Sources\iron\data\MaterialData.hx
iron\Sources\iron\data\ShaderData.hx


相机到纹理的绘制使用`Draw Camera to Texture`, 不需要指定材质。而绘制到材质节点是另一种使用渲染目标的方式,`Draw to Material Image` 需要更多的参数,并且这个节点使用到的 API 也更复杂,涉及了 `UniformsManager` 着色器常量管理器,以及 [Links] 着色器回调函数等等概念。如果没有一点 Iron 框架的材质处理的基础,根本没有办法使用这个节点。
首先,`Create Render Target Node` 创建的 RenderTarget,并就应该使用 On Init 这样的只执行一次事件去创建。RenderTarget 对象创建好并不直接返回供使用,而是将它交给着色器常量管理器 `UniformsManager` 进行统一管理,需要使用对应的资源时就通过 Links 回调函数获取,不同资源有不同的回调函数,对于纹理材质就是 `textureLink()`。所以 `Draw to Material Image` 会调用这个 API 获取纹理用来绘画。
其次,`Draw to Material Image` 节点是一个“开画布上下文”节点,所谓开画布上下文,即打开一个画布上下文对象,就像 `On Render2D` 那样,然后接上各种绘画节点,对画布进行绘画。因为可以对画布进行任意尺寸的绘制,所以内容可能会铺满整个游戏窗口。所绘制的纹理已经脱离具体几何体的边界约束,可以将图像绘制到屏幕的任意位置。之所以还需要指定对象、材质等参数,是因为 Iron 框架中,渲染目标需要依存于它们。
在绘画节点分组下,有一个 `DrawImageSequenceNode` 节点,它可以将序列帧纹理图像逐帧地绘制,同样,这种节点只需要 Start 一次触发就会循环地工作,只需要设置好图像名称的规则,如代码所示:
iron.data.Data.getImage(imagePrefix + i + '.' + imageExtension, (image: Image) -> { })
文件名如果是 0001.jpg ~ 0111.jpg 这种可能就难一点,代码显示只能是 abc1.jpg ~ abc111.jpg 这种规则,填充 0 值这种没有考虑。扩展名也不需要句点,还有就是指定发绘画区域的大小和起点坐标。从代码逻辑上看,Wait For Load 这个参数用来在完成加载后再绘制纹理图像,代码循环结构使用的是 `getImage()` 异步回调,循环结束后文件可能还在加载中。
`Set Material Image` 节点也是调用 `UniformsManager` 将指定的纹理图像注册。
使用 `UniformsManager` 和 Iron `Uniforms` 都可以注册着色器需要链接的资源,不同的是,后者可以直接注册回调函数。而常量管理器则自己注册好了各种回调函数来处理用户需要注册的资源。以下扩展脚本假设了场景中有一个名为 Plane.001 的对象,材质有 Image Texture 节点并命名为 ImageTexture。注意,如果同时存在逻辑节点与 Trait 脚本扩展,那么逻辑节点优先于脚本,脚本相应注册的资源失效。如果同时,存在 `UniformsManager` 和 Iron `Uniforms` 注册的资源,那么常量管理器的设置优先。`Uniforms` 注册的回调不一定有机会调用,以下代码就是因为 `UniformsManager` 重置了原有链接关系。



材质可以使用 `Material` 节点直接从现有材质中指定,或者使用 `Get Object Material` 节点从对象指定 Slot 中的材质,注意这个 Slot 从 0 计数。Blender 材质编辑器中的 Slot 从 1 开始。另外,如果材质未曾使用过,虽然它已经配置好了,但是要想在游戏运行正常使用,应该启用 **Fake User** 保护材质数据免被优化掉,导致数据读取错误。
`Draw to Material Image` 和 `Set Material Image Param` 节点在功能实现上有互斥,因为
前者已经包含了纹理链接回调函数的设置,而后者同样也是,只不过它还需要独占纹理的处理过程,需要在 Blender 材质编辑器侧栏面板勾选**Armory -> Armory Material Node -> Paramerter**。
这样才能在它的回调函数中获取到这个材质节点的回调处理,导致后果就是,其它节点读取不到这部分数据。
并且,`SetMaterialValueParamNode` 也是需要导出材质参数的配合,否则,是无法设置指定对象的材质的纹理图像的!这一点很容易忽略,导致无法意识到是哪里的问题,因为根本找到问题的根源,除非阅读源代码。就算是官方 Wiki 页面,也没有提供这些设置信息。
使用这这些节点还有一个问题,因为指定对象、材质分开指定,很容易产生不一致问题。假如,对象指定 Cube,但是材质却是来自另外一个对象,那么最终设置的纹理是影响 Cube 还另一个对象呢?
当然,设置纹理是对哪个材质,那么就应当产生的影响就归属于谁。这种不一致的问题似乎对软件质量有重大的影响。逻辑节点在设计时,应当考虑这样的问题,至少在节点接收了 Object 参数后,应该将材质端口隐藏,提供一个材质插槽选项,提供备选供用户选择,而不是让用户去挑选另外可能导致节点无法正常工作的输入。还有就是易用性问题,一般用户根本无法想象,一个材质设置节点竟然需要和材质编辑器一个角落中的一个选项配合使用,即使用知道,还容易忽略。
就目前而言,Armory 仅支持 3 种材质回调处理,它们对应了 3 个节点:
1. `SetMaterialRgbParamNode` 设置对象材质的颜色属性,使用数据类型 `iron.math.Vec4`;
2. `SetMaterialValueParamNode` 设置对象材质的数值属性,使用数据类型 `Null<kha.FastFloat>`;
3. `SetMaterialImageParamNode` 设置对象材质的纹理属性,使用数据类型 `kha.Image`;
C:\HaxeToolkit\armory_examples-22.06\material_params
C:\HaxeToolkit\armory_examples-22.06\material_decal_colors
C:\HaxeToolkit\armory_examples-22.06\material_shaders
Armory 逻辑节点易用性问题最严重的表现就是 `Draw To Material Image` 这个节点上。
Armory Wiki 内容这样描述 Draw To Material Image:
`DrawToMaterialImageNode` 这个节点本身不负责创建 Render Target,需要使用自行使用节点创建。节点逻辑设计有点复杂,它的代码逻辑上说明它的作用是:调用 Render Path API 为绘制纹理做准备。创建一个画布上下文对象并调用 `begin()` 方法打开绘图上下文对象,后续调用逻辑节点 Draw 分类节点,对材质的纹理图像进行绘画。这些绘画的节点连接到 Out 控制流输出端口上,表示对打开的 Canvas 画布上下文进行绘画,这个打开的画布就是材质对应的纹理图像。
除了 Object 只可以留空使用默认的 owner 对象,其它参数都必须指定。材质可以从用 `MaterialNode` 或者 `GetMaterialNode` 获取。默认的对象会由 `ObjectNode` 填充,它返回 `LogicTree` 也就是 Trait 类型的 object 属性。**注意,如果留空,就要挂载到正确的对象 Armory Traits 列表中!** 因为挂载的位置不正确,就获取不到正确的数据,这可以导致逻辑节点执行流程中断,并且不给出提示。
在设置参数,`Object` 和 `Material` 分别指要操作的对象和其材质对应的 Slot,但是 `Node` 这个参数就让人难以琢磨,是什么鬼?
这个参数是一个字符串值,会经由 `UniformsManager` API 的 link 参数传入,用于读写材质的相应属性数据。所以,材质节点中的 **Node** 这个字符串参数就是 `MaterialData` 的属性名称。每个材质节点的标题都会显示节点的名称,也可以在侧栏面板中编辑和复制它。在逻辑节点编辑器中使用材质属性设置节点时,就可以使用这个节点名称。在编程中,`Node` 对应的就是 `MaterialData` 属性名称。如果没有理解这层关系,那么根本无法理解材质属性设置节点的使用。
重点是 `UniformsManager` 这个着色器常量管理工具,GLSL 着色器中的 `uniform` 常量由其管理。Kha.Image `createRenderTarget()` 方法创建一个 RenderTarget 对象,就是一个纹理图像,然后注册到 `UniformsManager`,后续再链接到着色器程序进行显示。要对新创建的这个纹理绘画,就需要按`Uniforms` 类定义的接口,使用 [Links] 回调函数对其进行处理。`UniformsManager` 注册了默认的回调函数,调用它就可以获得纹理图像。就如 `DrawToMaterialImageNode` 节点的代码那样获取纹理,并打开画布上下文。注意 `begin()` 和 `end()` 方法之间的 `runOutput(0)`,就是它调用后续的逻辑节点,在画布上下文打开期间绘画:
可以在 Blender 脚本编辑器中使用 Python 代码逐步地探索当前选中对象的各种属性,如下:
但是,Armory 逻辑节点编辑器中的属性是指 iron.object.Object 对象系统下的对象属性。比如,使用 `GetPropertyNode` 节点获取属性值,就是在查询 Iron Objects 及其子类对象的属性集合。属性集合 properties 中的属性需要使用 SetPropertyNode 定义,它们和 Object 其它属性不同。比如 Get Object UID (GetUidNode) 节点直接通过 object.uid 或以获取对象的 UID,但是不能通过 properties 集合获取,因为没有定义。
`Draw Camera` 节点有两个控制流输入端口,Start 端口进入绘制逻辑,注册 `render()` 方法以处理
3D 渲染事件。注册 `render2D()` 方法以处理 2D 渲染事件。Stop 端口输入控制流就停止绘制,解除
事件侦听器。检查代码可知,Start 触发后就会注册相应的事件上侦听器,因此不能使用持续触发的控制流,
这会让内存爆满,并且重复触发渲染目标的绘制也不是正确的使用方式,应该使用 `On Init` 这种单次触发
事件流,而不能使用 `On Update` 这种持续触发的事件流。
绘制到 Render Target 对象上的图像来源自指定的 Camera 对象,这个相机的设置决定了来源图像的
大小比例。调用 `createRenderTarget()` 创建 Render Target,其大小比例由 `Draw Camera`
节点的参数指定,包括其 position 位置属性,后续使用纹理时决定其开始绘制位置。将相机视角的图像
绘制到 Render target 对象这个过程就有一次变换,设置不同的宽高值就可能产生变形。
逻辑节点代码中的 runOutput(0) 和 runOutput(1) 就是执行相应的控制流输出端口,即对应节点的
**On Start** 和 **On Stop**,等价于触发两个事件:
另外 `Get Mouse Movement` 似乎还有问题,不能获取鼠标移动的值,以及滚轮值。经过调试发现,节点没有在每次鼠标数据更新时被执行,也就是没有获取最新数据。这一原由来自于将这些数据连接到了一个数组节点上,`ArrayStringNode`,对的,就是因为数组节点具有单次初始化后就不再更新数据的这个逻辑。为了将数据集中处理,还可以使用 `ConcatenateStringNode` 这样的节点以连接多个变量构成字符串。还需要注意的是 x y 输出端口其实是 movementX movementY,这种端口名称令人多有误解。
Kha Graphics APIs 分成多种类型集合,其中 G3 缺失,只是占位而已:
1. G1 - `kha.graphics1` Just provides a framebuffer you can write into
2. G2 - `kha.graphics2` Provides a basic and very portable 2D graphics-API
3. G3 - `kha.graphics3` Old-school 3D graphics API similar to early OpenGL.
4. G4 - `kha.graphics4` 3D graphics API similar to Direct3D 11 or modern OpenGL
5. G5 - only exists in Kinc
部分 kha targets 并不支持 graphics4,参考手册中的 [Feature Matrix] 。