自定义着色器效果

虽然 Solar2D 拥有丰富的内置着色器效果列表,但有时您可能需要创建自定义效果。本指南概述了如何使用自定义着色器代码创建自定义效果,其结构与 Solar2D 的内置着色器效果的实现方式相同。

注意

GPU 渲染管线

在可编程图形管线中,GPU 被视为流处理器。数据流经多个处理单元,每个单元都能够运行一个(着色器)程序。

在 OpenGL-ES 2.0 中,数据从 (1) 应用程序流向 (2) 顶点处理器,再流向 (3) 片段处理器,最后流向 (4) 帧缓冲区/屏幕。

在 Solar2D 中,自定义着色器效果以顶点和片段**内核**的形式公开,而不是编写完整的着色器程序,这使您可以创建强大的可编程效果。

可编程效果

Solar2D 允许您扩展其管线以创建多种类型的自定义可编程效果,这些效果根据输入纹理的数量进行组织

创建自定义效果

要定义新效果,请调用 graphics.defineEffect(),并传入一个定义效果的 Lua 表。为了使此表成为有效的 effect 定义,它必须包含以下几个属性:

所有属性的完整详细说明可在 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 您的着色器需要 GPU重新渲染场景,即使场景中显示对象没有其他更改也是如此。您可以通过在内核定义中设置 kernel.isTimeDependent 属性来做到这一点,如下所示。请注意,只有当您的着色器代码真正与时间相关与时间相关重新渲染时才应设置此属性,因为它会强制 GPU

kernel.isTimeDependent = true

大小

顶点内核可以访问以下大小统一变量

  • P_POSITION vec2 CoronaContentScale — 沿 **x** 和 **y** 轴的每个屏幕像素的内容像素数。内容像素是指 Solar2D 的坐标系,由项目的内容缩放设置决定。

  • P_UV vec4 CoronaTexelSize — 这些值可帮助您了解归一化纹理像素(纹素)与实际像素的关系。这很有用,因为纹理坐标是归一化的01,通常您只有关于比例的信息(纹理宽度或高度的百分比)。实际上,这些值可以帮助您根据实际屏幕/内容像素距离创建效果。

定义
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/颜色

所有显示对象都有一个alpha属性。此外,形状对象有一个颜色,可以通过object:setFillColor()或颜色通道属性设置rgba对象的fill属性。

通常,您的着色器应将这些属性的效果合并到片段内核返回的颜色中。您可以通过调用以下函数来计算正确的颜色来做到这一点

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 中,您可以通过在 ShapeObjecteffect 上设置适当的属性来传递效果参数。这些属性取决于效果。例如,亮度内置滤镜有一个 intensity 参数,可以传播到着色器代码

object.fill.effect = "filter.brightness"
object.fill.effect.intensity = 0.4

Solar2D 支持两种方法将参数添加到自定义着色器效果。这些方法是互斥的,因此您必须选择其中之一。

方法 描述
顶点用户数据 参数在每个顶点的基础上传递。这通常性能更好,因为顶点数据的更改不需要 OpenGL 状态更改。但是,它仅限于 4 个(标量)值。
统一用户数据 参数作为 uniforms 传递。这是用于需要比通过顶点用户数据传递更多参数的效果。
顶点与 Uniform

在设备上,当您能够最小化状态更改时,OpenGL 的性能最佳。这是因为如果显示对象之间不需要状态更改,则可以将多个对象批处理到单个绘制调用中。

通常,当您需要传入效果参数时,最好使用顶点用户数据,因为参数数据可以在顶点数组中传递。这最大限度地提高了 Solar2D 可以将绘制调用批处理在一起的机会。如果您有许多连续的显示对象应用了相同的效果,则尤其如此。

顶点用户数据

使用顶点用户数据传递效果参数时,会为每个顶点复制效果参数。为了最大限度地减少数据大小的影响,效果参数被限制为 vec4(4 个浮点数的向量)。这在顶点和片段内核中作为以下只读向量变量提供

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 = 0CoronaVertexUserData 向量。

kernel.vertexData =
{
    {
        name = "brightness",
        default = 0, 
        min = 0,
        max = 1,
        index = 0,  -- This corresponds to "CoronaVertexUserData.x"
    },
}

在上面的数组 (kernel.vertexData) 中,每个元素都是一个表,每个表指定

  • name — 在 Lua 中公开的参数的 字符串 名称。
  • default — 默认值。
  • min — 最小值。
  • max — 最大值。
  • indexCoronaVertexUserData 中相应向量组件的索引

    index = 0CoronaVertexUserData.x
    index = 1CoronaVertexUserData.y
    index = 2CoronaVertexUserData.z
    index = 3CoronaVertexUserData.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 约定和最佳实践

GLSL 在移动设备和桌面设备上有多种风格. Solar2D 假设使用 GLSL ES(OpenGL ES 2.0)。为了最大限度地提高兼容性和性能,您应该遵循以下约定和最佳实践。

Solar2D 模拟器与设备

性能

桌面 GPU 上的着色器性能与设备上的着色器性能**不同**。因此,如果您在 Solar2D 模拟器或 Solar2D Shader Playground 中运行着色器,则应在实际设备上运行它,以确保获得所需的性能。

另请注意,如果您支持不同制造商的设备,则它们之间的性能可能会有很大差异。尤其是在 Android 上,某些高端设备的 GPU 实际上性能不足,因此您**不应**假设您会在不同的高端Android 设备

语法

上获得相同的性能。 Solar2D 模拟器使用**桌面** GLSL 编译您的着色器。因此,如果您在 Solar2D 模拟器中运行着色器,您的着色器可能仍然包含 GLSL ES 错误,这些错误在您尝试在设备上运行着色器之前不会出现。

如果您有一个仅片段内核的着色器效果,您可以在 Solar2D Shader Playground 中测试您的着色器代码. 该 playground 在启用了WebGL的浏览器中针对 GLSL ES 进行验证。

精度限定符宏

与其他风格的 GLSL 不同,GLSL ES(OpenGL ES 2.0)通常要求在变量声明中指定精度限定符。因此,明确精度是一个好习惯.

您应该使用以下精度限定符宏之一,而不是使用像 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
}

预乘 Alpha

Solar2D 提供具有 预乘 alpha 的纹理。因此,您可能需要除以 alpha 来恢复原始 RGB 值。但是,出于性能原因,您应尝试执行计算以避免除法。比较以下两个使图像变亮的内核

  1. 在以下内容中,通过撤消预乘alpha 来恢复原始 RGB 值,稍后将重新应用alpha。这并不理想,因为它会为每个像素在 GPU 上生成大量额外的操作。
// 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 );
}
  1. 此版本将纹理的 alpha 预乘到 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

避免动态纹理查找

当片段着色器在与传递到着色器的纹理坐标不同的位置采样纹理时,会导致动态纹理查找,也称为“依赖纹理读取”。OpenGL-ES 2.0中,依赖纹理读取会延迟纹素数据加载并降低性能。这就是为什么某些对纹素区域进行采样的效果(例如模糊效果)较慢的原因。

相反,没有依赖纹理读取的效果使 GPU 能够在预取着色器执行之前获取纹素数据,从而减少 I/O 延迟。

避免分支和循环

分支指令(if 条件)开销很大。在可能的情况下,for 循环应该展开或替换为向量运算。

精度问题

Solar2D 的着色器使用 IEEE-754 浮点数 作为数字的底层表示形式。

在大多数情况下(此处无关的例外情况),浮点数的一部分指定一个整数分子。我们称之为 N。我们的分子可以从 0D - 1,其中 D 是 2 的固定幂。这些共同为我们提供了范围 [0, 1) 中的比例因子 t = N / D

数字的其余部分用于符号(正或负)和指数,后者是另一个整数,它为我们提供了 2 的幂,例如 2-3 或 25

我们通过使用比例因子在相邻幂之间进行插值来解码我们的数字,指数为 pp + 1result = 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 端进行一些更常见的操作,并传递更好的结果。