虽然 Solar2D 拥有丰富的内置着色器效果列表,但有时您可能需要创建自定义效果。本指南概述了如何使用自定义着色器代码创建自定义效果,其结构与 Solar2D 的
编写自定义效果是一项高级开发者功能。如果您想利用此功能,本指南假设您已经熟悉并精通 GLSL ES
自定义效果在 iOS、Android、macOS 桌面和 Win32 桌面系统上受支持。
在可编程图形管线中,GPU 被视为流处理器。数据流经多个处理单元,每个单元都能够运行一个(着色器)程序。
在 OpenGL-ES 2.0 中,数据从 (1) 应用程序流向 (2) 顶点处理器,再流向 (3) 片段处理器,最后流向 (4) 帧缓冲区/屏幕。
在 Solar2D 中,自定义着色器效果以顶点和片段**内核**的形式公开,而不是编写完整的着色器程序,这使您可以创建强大的可编程效果。
Solar2D 允许您扩展其管线以创建多种类型的自定义可编程效果,这些效果根据输入纹理的数量进行组织
要定义新效果,请调用 graphics.defineEffect(),并传入一个定义效果的 Lua 表。为了使此表成为有效的 effect 定义,它必须包含以下几个属性:
category
— 效果的类型。name
— 给定类别中的名称。vertex
和/或 fragment
— 定义着色器代码的位置,如下面的定义内核部分所述。所有属性的完整详细说明可在 graphics.defineEffect() 文档中找到。
效果的名称由以下属性确定
category
— 效果的类型。group
— 效果的组。如果未提供,Solar2D 将假定为 custom
。name
— 给定类别中的名称。在显示对象上设置效果时,您必须提供.
分隔每个值,如下所示
local effectName = "[category].[group].[name]"
Solar2D 将着色器代码片段打包成**内核**的形式. 通过在内核中构建特定的顶点和片段处理任务,可以大大简化自定义效果的创建。
本质上,内核是主要着色器程序依赖的用于处理特定处理任务的着色器代码。Solar2D 支持顶点内核和片段内核。您必须在效果中至少指定一种内核类型(或两者)。如果未指定顶点/片段内核,Solar2D 将分别插入默认的顶点/片段内核。
顶点内核对每个顶点进行操作,使您能够在顶点位置用于管线的下一阶段之前修改它们。它们必须定义以下函数,该函数接受传入的位置并可以修改该顶点位置。
Solar2D 的默认顶点内核只是返回传入的位置
P_POSITION vec2 VertexKernel( P_POSITION vec2 position ) { return position; }
顶点内核可以访问以下时间统一变量
P_DEFAULT float CoronaTotalTime
— 应用程序运行时间(以秒为单位)。P_DEFAULT float CoronaDeltaTime
— 自上一帧以来的时间(以秒为单位)。如果在内核的着色器代码中使用这些变量,则您的内核隐式地
使用这些变量时,您需要告诉 Solar2D 您的着色器需要 GPUkernel.isTimeDependent
属性来做到这一点,如下所示。请注意,只有当您的着色器代码真正
kernel.isTimeDependent = true
顶点内核可以访问以下大小统一变量
P_POSITION vec2 CoronaContentScale
— 沿 **x** 和 **y** 轴的每个屏幕像素的内容像素数。内容像素是指 Solar2D 的坐标系,由项目的内容缩放设置决定。
P_UV vec4 CoronaTexelSize
— 这些值可帮助您了解归一化纹理像素(纹素)与实际像素的关系。这很有用,因为纹理坐标是归一化的0
到 1
)
值 | 定义 |
---|---|
CoronaTexelSize.xy |
沿 **x** 和 **y** 轴的每个**屏幕**像素的纹素数。 |
CoronaTexelSize.zw |
沿 **x** 和 **y** 轴的每个**内容**像素的纹素数,最初与 CoronaTexelSize.xy 相同。这在创建CoronaContentScale 。 |
P_UV vec2 CoronaTexCoord
— 顶点的纹理坐标。以下示例使图像的底部边缘以固定幅度摆动
local kernel = {} -- "filter.custom.myWobble" kernel.category = "filter" kernel.name = "myWobble" -- Shader code uses time environment variable CoronaTotalTime kernel.isTimeDependent = true kernel.vertex = [[ P_POSITION vec2 VertexKernel( P_POSITION vec2 position ) { P_POSITION float amplitude = 10; position.y += sin( 3.0 * CoronaTotalTime + CoronaTexCoord.x ) * amplitude * CoronaTexCoord.y; return position; } ]]
片段内核对每个像素进行操作,使您能够修改每个像素
Solar2D 的默认片段内核只是对单个纹理 (CoronaSampler0
) 进行采样,并使用 CoronaColorScale()
通过显示对象的 alpha/颜色对其进行调制
P_COLOR vec4 FragmentKernel( P_UV vec2 texCoord ) { P_COLOR vec4 texColor = texture2D( CoronaSampler0, texCoord ); return CoronaColorScale( texColor ); }
片段内核可以访问与顶点内核时间相同的统一变量。
片段内核可以访问与顶点内核大小相同的统一变量。
P_COLOR sampler2D CoronaSampler0
— 第一个纹理的纹理采样器。P_COLOR sampler2D CoronaSampler1
— 第二个纹理的纹理采样器(需要复合绘画)。所有显示对象都有一个alpha属性。此外,形状对象有一个颜色,可以通过object:setFillColor()或颜色通道属性设置
通常,您的着色器应将这些属性的效果合并到片段内核返回的颜色中。您可以通过调用以下函数来计算正确的颜色来做到这一点
P_COLOR vec4 CoronaColorScale( P_COLOR vec4 color );
此函数接受一个输入颜色向量(红色、绿色、蓝色和 alpha 通道),并返回一个由显示对象的 alpha 和颜色调制后的颜色向量,如片段内核示例所示。通常,您应该在片段内核的末尾调用此函数,以便正确计算片段内核应返回的颜色向量。
以下示例通过每个颜色分量的固定量使图像变亮
local kernel = {} -- "filter.custom.myBrighten" kernel.category = "filter" kernel.name = "myBrighten" kernel.fragment = [[ P_COLOR vec4 FragmentKernel( P_UV vec2 texCoord ) { P_COLOR float brightness = 0.5; P_COLOR vec4 texColor = texture2D( CoronaSampler0, texCoord ); // Pre-multiply the alpha to brightness brightness = brightness * texColor.a; // Add the brightness texColor.rgb += brightness; // Modulate by the display object's combined alpha/tint. return CoronaColorScale( texColor ); } ]]
对于与时间相关的顶点或片段内核,Solar2D 还将查找 `timeTransform`。如果存在,它必须是一个表,其 `func` 成员为以下之一:`"modulo"`、`"pingpong"`、`"sine"`。着色器中 `CoronaTotalTime` 的值将是任何此类转换的结果,而不是原始基础时间。
`"modulo"` 变换计算为 `CoronaTotalTime = OriginalTotalTime % range`,其中 `range` 是一个正数,可以在 `timeTransform` 表中以相同的键提供。默认情况下,`range` 为 1。
`"pingpong"` 变换类似,只是 `CoronaTotalTime` 将首先从 0 变为 `range`(在这种情况下没有默认值),然后回落到 0,并无限重复。
`"sine"` 变换计算为 `CoronaTotalTime = amplitude * sin(scale * OriginalTotalTime + phase)`。同样,`amplitude` 和 `phase` 可以在 `timeTransform` 表中提供,默认值分别为 1 和 0。比例是根据 `period` 参数计算的,该参数是一个正数,指示正弦波重复之前应该经过的时间。默认值为 2 * π,对应于比例因子 1。
graphics.defineEffect{ category = "generator", group = "time", name = "pingpong", isTimeDependent = true, timeTransform = { func = "pingpong", range = 5 }, fragment = [[ P_COLOR vec4 FragmentKernel (P_UV vec2 _) { return vec4(0., CoronaTotalTime / 5., 0., 1.); } ]] } local rect = display.newRect(300, 100, 50, 50) rect.fill.effect = "generator.time.pingpong"
有关这些转换背后的动机,请参阅下面的精度问题。
“可变”变量允许将数据从顶点着色器传递到片段着色器。顶点着色器输出此值,该值对应于基元顶点的位置。反过来,片段着色器在光栅化期间对基元上的该值进行线性插值。
在 Solar2D 中,您可以在着色器代码中声明自己的可变变量。您应该将它们放在顶点和片段代码的开头。
以下示例结合了 wobble 顶点内核和 brighten 片段内核。与上面 "myBrighten"
片段示例不同,此版本不使用固定的亮度值。相反,顶点着色器为每个顶点计算一个振荡亮度值,片段着色器根据其着色的像素线性插值亮度值。
local kernel = {} kernel.category = "filter" kernel.name = "wobbleAndBrighten" -- Shader code uses time environment variable CoronaTotalTime kernel.isTimeDependent = true kernel.vertex = [[ varying P_COLOR float delta; // Custom varying variable P_POSITION vec2 VertexKernel( P_POSITION vec2 position ) { P_POSITION float amplitude = 10; position.y += sin( 3.0 * CoronaTotalTime + CoronaTexCoord.x ) * amplitude * CoronaTexCoord.y; // Calculate value for varying delta = 0.4*(CoronaTexCoord.y + sin( 3.0 * CoronaTotalTime + 2.0 * CoronaTexCoord.x )); return position; } ]] kernel.fragment = [[ varying P_COLOR float delta; // Matches declaration in vertex shader P_COLOR vec4 FragmentKernel( P_UV vec2 texCoord ) { // Brightness changes based on interpolated value of custom varying variable P_COLOR float brightness = delta; P_COLOR vec4 texColor = texture2D( CoronaSampler0, texCoord ); // Pre-multiply the alpha to brightness brightness *= texColor.a; // Add the brightness texColor.rgb += brightness; // Modulate by the display object's combined alpha/tint. return CoronaColorScale( texColor ); } ]]
在 Solar2D 中,您可以通过在 ShapeObject 的 effect 上设置适当的属性来传递效果参数。这些属性取决于效果。例如,亮度intensity
参数,可以传播到着色器代码
object.fill.effect = "filter.brightness" object.fill.effect.intensity = 0.4
Solar2D 支持两种方法将参数添加到自定义着色器效果。这些方法是互斥的,因此您必须选择其中之一。
方法 | 描述 |
---|---|
顶点用户数据 | 参数在 |
统一用户数据 | 参数作为 uniforms 传递。这是用于需要比通过顶点用户数据传递更多参数的效果。 |
在设备上,当您能够最小化状态更改时,OpenGL 的性能最佳。这是因为如果显示对象之间不需要状态更改,则可以将多个对象批处理到单个绘制调用中。
通常,当您需要传入效果参数时,最好使用顶点用户数据,因为参数数据可以在顶点数组中传递。这最大限度地提高了 Solar2D 可以将绘制调用批处理在一起的机会。如果您有许多连续的显示对象应用了相同的效果,则尤其如此。
使用顶点用户数据传递效果参数时,会为每个顶点复制效果参数。为了最大限度地减少数据大小的影响,效果参数被限制为 vec4
P_DEFAULT vec4 CoronaVertexUserData
例如,假设您要修改上面的 "filter.custom.myBrighten"
效果示例,以便在 Lua 中,效果有一个 "brightness"
参数
object.fill.effect = "filter.custom.myBrighten" object.fill.effect.brightness = 0.3
要实现这一点,您必须指示 Solar2D 将 Lua 中的参数名称与 CoronaVertexUserData
返回的向量中的相应组件映射。以下代码告诉 Solar2D "brightness"
参数是第一个组件index = 0
)CoronaVertexUserData
向量。
kernel.vertexData = { { name = "brightness", default = 0, min = 0, max = 1, index = 0, -- This corresponds to "CoronaVertexUserData.x" }, }
在上面的数组 (kernel.vertexData
) 中,每个元素都是一个表,每个表指定
name
— 在 Lua 中公开的参数的 字符串 名称。default
— 默认值。min
— 最小值。max
— 最大值。index
— CoronaVertexUserData
中相应向量组件的索引
index = 0
→ CoronaVertexUserData.x
index = 1
→ CoronaVertexUserData.y
index = 2
→ CoronaVertexUserData.z
index = 3
→ CoronaVertexUserData.w
最后,修改 FragmentKernel
以读取参数值,通过 CoronaVertexUserData
访问参数值
kernel.fragment = [[ P_COLOR vec4 FragmentKernel( P_UV vec2 texCoord ) { P_COLOR float brightness = CoronaVertexUserData.x; ... } ]]
(即将推出的功能)
如果设备支持,您也可以在顶点内核中采样纹理。在 Solar2D 方面,调用 system.getInfo(“maxVertexTextureUnits”) 将返回可用采样器的数量作为返回值。这在顶点内核中也可用作 gl_MaxVertexTextureImageUnits
。
任何用于顶点内核的纹理都应使用设置为 "nearest"
的 采样过滤器 并使用细节级别 (Lod
) 采样来创建。您可以在顶点代码中手动将采样器声明为 uniform sampler2D NAME
,其中 NAME
是您选择的未使用的名称。(**TODO**:如果有多个采样器,声明顺序*可能*与采样器顺序对应,但这需要确认)
kernel.vertex = [[ uniform sampler2D us_Vertices; // vertex sampler #1 P_POSITION vec2 VertexKernel (P_POSITION vec2 pos) { if (gl_MaxVertexTextureImageUnits > 0) { ... P_COLOR vec4 verts = texture2DLod(us_Vertices, vec2(offset, 0.), 0.); ... } else return vec4(0.); } ]]
GLSL 在移动设备和桌面设备上有多种风格. Solar2D 假设使用 GLSL ES
桌面 GPU 上的着色器性能与设备上的着色器性能**不同**。因此,如果您在 Solar2D 模拟器或 Solar2D Shader Playground 中运行着色器,则应在实际设备上运行它,以确保获得所需的性能。
另请注意,如果您支持不同制造商的设备,则它们之间的性能可能会有很大差异。尤其是在 Android 上,某些
上获得相同的性能。 Solar2D 模拟器使用**桌面** GLSL 编译您的着色器。因此,如果您在 Solar2D 模拟器中运行着色器,您的着色器可能仍然包含 GLSL ES 错误,这些错误在您尝试在设备上运行着色器之前不会出现。
如果您有一个仅片段内核的着色器效果,您可以在 Solar2D Shader Playground 中测试您的着色器代码. 该 playground 在启用了
与其他风格的 GLSL 不同,GLSL ES
您应该使用以下精度限定符宏之一,而不是使用像 lowp
这样的原始精度限定符。默认值针对数据类型进行了优化
P_DEFAULT
— 对于通用值;默认值为 highp
。P_RANDOM
— 对于随机值;默认值为 highp
。P_POSITION
— 对于位置;默认值为 mediump
。P_NORMAL
— 对于法线;默认值为 mediump
。P_UV
— 对于纹理坐标;默认值为 mediump
。P_COLOR
— 对于像素颜色;默认值为 lowp
。我们强烈建议您使用 Solar2D 的默认着色器精度设置,所有这些设置都经过优化以平衡性能和保真度。但是,您的项目可以在 config.lua
中覆盖这些设置(指南)。
并非所有设备都支持高精度。因此,如果您的内核需要高精度,则应使用 GL_FRAGMENT_PRECISION_HIGH
宏。如果设备支持高精度,则为 1
,否则为未定义。
如果设备不支持 highp
,您的内核可以通过编写两个实现来优雅地降级
P_COLOR vec4 FragmentKernel( P_UV vec2 texCoord ) { #ifdef GL_FRAGMENT_PRECISION_HIGH // Code path for high precision calculations #else // Code path for fallback #endif }
Solar2D 提供具有 预乘 alpha 的纹理。因此,您可能需要除以 alpha 来恢复原始 RGB 值。但是,出于性能原因,您应尝试执行计算以避免除法。比较以下两个使图像变亮的内核
// Non-optimal Version P_COLOR vec4 FragmentKernel( P_UV vec2 texCoord ) { P_COLOR float brightness = 0.5; P_COLOR vec4 texColor = texture2D( CoronaSampler0, texCoord ); // BAD: Recover original RGBs via divide texColor.rgb /= texColor.a; // Add the brightness texColor.rgb += brightness; // BAD: Re-apply the pre-multiplied alpha texColor.rgb *= texColor.a; return CoronaColorScale( texColor ); }
brightness
变量,以便可以直接将其添加到纹理的 RGB 值。这规避了上述实现的缺陷.// Optimal Version P_COLOR vec4 FragmentKernel( P_UV vec2 texCoord ) { P_COLOR float brightness = 0.5; P_COLOR vec4 texColor = texture2D( CoronaSampler0, texCoord ); // GOOD: Pre-multiply the alpha to brightness brightness = brightness * texColor.a; // Add the brightness texColor.rgb += brightness; return CoronaColorScale( texColor ); }
某些设备没有配备矢量处理器的 GPU。在这些情况下,矢量计算可以在标量处理器上执行。通常,您应仔细考虑着色器中的运算顺序,以确保在标量处理器上避免不必要的计算。
在以下示例中,矢量处理器将并行执行每个乘法。但是,由于运算顺序的原因,标量处理器将执行 8 次乘法,即使三个参数中只有一个是标量值。
P_DEFAULT float f0, f1; P_DEFAULT vec4 v0, v1; v0 = (v1 * f0) * f1; // BAD: Multiply each scalar to a vector
更好的顺序是先将两个标量相乘,然后将结果与向量相乘。这将计算减少到 5 次乘法。
highp float f0, f1; highp vec4 v0, v1; v0 = v1 * (f0 * f1); // GOOD: Multiply scalars first
当您的向量计算不使用所有组件时,类似的逻辑也适用。“写入掩码”允许您将计算限制为仅掩码中指定的组件。以下内容在标量处理器上运行速度快两倍,因为写入掩码用于指定只需要四个组件中的两个。
highp vec4 v0; highp vec4 v1; highp vec4 v2; v2.xz = v0 * v1; // GOOD: Write mask limits calculations
当片段着色器在与传递到着色器的纹理坐标不同的位置采样纹理时,会导致动态纹理查找,也称为
相反,没有依赖纹理读取的效果使 GPU 能够在
分支指令(if
条件)开销很大。在可能的情况下,for
循环应该展开或替换为向量运算。
Solar2D 的着色器使用 IEEE-754 浮点数 作为数字的底层表示形式。
在大多数情况下(此处无关的例外情况),浮点数的一部分指定一个整数分子。我们称之为 N
。我们的分子可以从 0
到 D - 1
,其中 D
是 2 的固定幂。这些共同为我们提供了范围 [0, 1) 中的比例因子 t = N / D
。
数字的其余部分用于符号(正或负)和指数,后者是另一个整数,它为我们提供了 2 的幂,例如 2-3 或 25。
我们通过使用比例因子在相邻幂之间进行插值来解码我们的数字,指数为 p
和 p + 1
:result = 2^p * (1 + t)
。请注意,如果 t
为 1,我们将处于 2 的**下一个**幂。
这可以精确地表示某些值,但只会近似大多数值。任何格式都会有权衡。IEEE-754 在接近 0 时提供相当高的精度,以及一直到 2 * D
的精确整数。
在 CPU 方面,Lua 为我们提供了 64 位浮点数,以及相当慷慨的 52 位分子。在 GPU 上,我们很少有这么幸运,尤其是在移动硬件上,这是由于带宽和内存等问题。
例如,请参阅 OpenGL ES 2.0 参考卡中的 “限定符” 部分。使用 **mediump**,我们的 D
只能保证是令人失望的 1024。
现在想象一下这对以秒测量的時間意味着什么。起初,一切正常。但就在两分钟之后,在 128 和 256 之间进行插值,我们只能以 (256 - 128) / 1024,即 1/8 秒的步长前进。五分钟后,我们将以 1/4 秒的增量前进,依此类推。任何依赖这种结果的东西都会变得非常不流畅。
然而,这种情况比实际情况要悲观。时间实际上在 Solar2D 中作为单精度浮点数维护,具有相当可观的 23 位分子;损失发生在它传递到 GPU 之后。此外,许多着色器需要转换后的结果,例如 `TrueTotalTime % X` 或 `sin(N * TrueTotalTime)`,它们的绝对值很可能位于更精确的较低数值范围内。时间转换使我们能够在 Solar2D 端进行一些更常见的操作,并传递更好的结果。