使用预乘 alpha 时,WebGL 中的 Alpha 渐变不平滑

Alpha gradients not smooth in WebGL when using premultiplied alpha

我有一个 LibGDX 游戏,其中有平滑渐变的卡通云。游戏中还有其他渐变示例也存在类似问题,但云彩是最明显的示例。它们在 Android、iOS 和桌面版游戏中看起来不错,但在 WebGL 版本中,渐变绘制得不那么平滑。似乎只有 alpha 梯度有问题。其他渐变看起来还可以。

我在 Chrome 和 IE 中尝试了 3 种不同的设备,所有 3 种设备都产生了相同的结果。您可以在此处找到 HTML5 版本的测试。

https://wordbuzzhtml5.appspot.com/canvas/

我在 github 此处添加了一个示例 IntelliJ 项目

https://github.com/WillCalderwood/CloudTest

如果您有 intelliJ,请克隆该项目,打开 build.gradle 文件,按 Alt-F12,键入 gradlew html:superdev,然后浏览至 http://localhost:8080/html/

关键代码是render()here

这里底部的图像是桌面版,顶部是 WebGL 版,两者 运行 在同一硬件上。

这幅画没有什么巧妙之处。只是调用

    spriteBatch.draw(texture, getLeft(), getBottom(), getWidth(), getHeight());

我正在使用默认着色器,纹理包含预乘 alpha,混合函数设置为

    spriteBatch.setBlendFunction(GL20.GL_ONE, GL20.GL_ONE_MINUS_SRC_ALPHA);

这是实际图像,尽管 alpha 没有像我的打包程序那样预乘。

有谁知道这可能的原因以及我该如何解决?

更新

这似乎只在使用混合模式时发生 GL20.GL_ONE, GL20.GL_ONE_MINUS_SRC_ALPHA

另一个更新

我试过将整个游戏更改为使用非预乘 alpha 纹理。我使用 Texture Packer,它可以帮助修复非预乘 alpha 经常出现的光晕问题。所有这些在 Android 和桌面版本中都可以正常工作。在 WebGL 版本中,虽然我得到了平滑的渐变,但我仍然得到了一个小的光晕效果,所以我也不能用这个作为解决方案。

还有一个更新

这是一张新图片。桌面版在上,网页版在下。混合模式左边GL20.GL_ONE, GL20.GL_ONE_MINUS_SRC_ALPHA,右边GL20.GL_SRC_ALPHA, GL20.GL_ONE_MINUS_SRC_ALPHA

这是左下角图像的放大版本,增加了对比度以显示问题。

我也对片段着色器做了很多尝试,试图找出发生了什么。如果我设置

gl_FragColor = vec4(c.a, c.a, c.a, 1.0);

那么渐变是平滑的,但是如果我设置

gl_FragColor = vec4(c.r, c.r, c.r, 1.0);

然后我得到条带。我认为这指向一个精度问题,因为颜色通道已被预乘过程挤压到光谱的较暗端。

WebGL 处理 alpha 的方式与标准 OpenGL 略有不同,并且经常会导致问题。

This site 很好地解释了这些差异。

The biggest difference between OpenGL and WebGL is that OpenGL renders to a backbuffer that is not composited with anything so, or effectively not composited with anything by the OS's window manager, so it doesn't matter what your alpha is.

WebGL is composited by the browser with the web page and the default is to use pre-multiplied alpha the same as .png tags with transparency and 2d canvas tags.*

该站点还针对人们面临的典型问题提供了解决方法。这有点复杂,但应该可以解决您的问题。

我不打算在这里粘贴整篇文章,但我怀疑您最好坚持使用非预乘,并确保在每次渲染后清除 alpha 通道。该站点将提供更多详细信息。

我想知道您是否遇到了某个地方的精度问题 - 预乘 alpha 纹理使颜色通道比原始通道更暗。

从概念上讲,此过程将颜色值压缩到颜色范围的底端,当您重新编码为 8 位纹理时,这可能会导致量化条带。我无法解释的是为什么你会在明暗之间振荡,除非这是颜色和 alpha 通道中不同波段跨度的相互作用。

OpenGL 和 OpenGL ES 3.0 支持 sRGB 纹理,这可以帮助解决这个问题(实际上它们更能表达光谱暗端的颜色差异,牺牲眼睛无法区分它们的高亮度值).不幸的是,这在 OpenGL ES 2.0 移动设备上并没有广泛可用(因此在 WebGL 上也没有,因为 WEbGL 基于 ES 2.0,尽管 sRGB 扩展可能在某些设备上可用)。

我花了一天中最好的时间来研究这个问题,因为我也看到了这个确切的问题。我想我终于弄明白了。

这是由 libGDX 加载图像的方式引起的。在所有平台上从 Pixmap 创建纹理,其中 Pixmap 基本上是 in-memory 可变图像。这是在核心库中用 some native code 实现的(大概是为了速度)。

但是,由于原生代码在浏览器中显然是不可能的,所以Pixmapa different implementation in the GWT backend。最突出的部分是构造函数:

public Pixmap (FileHandle file) {
    GwtFileHandle gwtFile = (GwtFileHandle)file;
    ImageElement img = gwtFile.preloader.images.get(file.path());
    if (img == null) throw new GdxRuntimeException("Couldn't load image '" + file.path() + "', file does not exist");
    create(img.getWidth(), img.getHeight(), Format.RGBA8888);
    context.setGlobalCompositeOperation(Composite.COPY);
    context.drawImage(img, 0, 0);
    context.setGlobalCompositeOperation(getComposite());
}

这将创建一个 HTMLCanvasElement 和一个 CanvasRenderingContext2D,然后将图像绘制到 canvas。这在 libGDX 上下文中是有意义的,因为 Pixmap 应该是可变的,但是 HTML 图像是 read-only.

我不确定最终如何再次检索像素以上传到 OpenGL 纹理,但此时我们已经注定失败。因为在 canvas2d spec:

中注意这个警告

Note: Due to the lossy nature of converting to and from premultiplied alpha color values, pixels that have just been set using putImageData() might be returned to an equivalent getImageData() as different values.

为了显示效果,我创建了一个 JSFiddle:https://jsfiddle.net/gg9tbejf/ 这不使用 libGDX,仅使用原始 canvas、JavaScript 和 WebGL,但您可以看到图像在 round-trip 到 canvas2d.

之后被肢解了

显然大多数(所有?)主流浏览器都使用预乘 alpha 存储它们的 canvas2d 数据,因此无损恢复是不可能的。 This SO question 相当明确地表明目前没有办法解决这个问题。


编辑:我在我的本地项目中写了一个解决方法,没有修改 libGDX 本身。在您的 GWT 项目中创建 ImageTextureData.java(包名称很重要;它访问 package-private 字段):

package com.badlogic.gdx.backends.gwt;

import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.graphics.GL20;
import com.badlogic.gdx.graphics.Pixmap;
import com.badlogic.gdx.graphics.TextureData;
import com.badlogic.gdx.utils.GdxRuntimeException;
import com.google.gwt.dom.client.ImageElement;
import com.google.gwt.webgl.client.WebGLRenderingContext;

public class ImageTextureData implements TextureData {

    private final ImageElement imageElement;
    private final Pixmap.Format format;
    private final boolean useMipMaps;

    public ImageTextureData(ImageElement imageElement, Pixmap.Format format, boolean useMipMaps) {
        this.imageElement = imageElement;
        this.format = format;
        this.useMipMaps = useMipMaps;
    }

    @Override
    public TextureDataType getType() {
        return TextureDataType.Custom;
    }

    @Override
    public boolean isPrepared() {
        return true;
    }

    @Override
    public void prepare() {
    }

    @Override
    public Pixmap consumePixmap() {
        throw new GdxRuntimeException("This TextureData implementation does not use a Pixmap");
    }

    @Override
    public boolean disposePixmap() {
        throw new GdxRuntimeException("This TextureData implementation does not use a Pixmap");
    }

    @Override
    public void consumeCustomData(int target) {
        WebGLRenderingContext gl = ((GwtGL20) Gdx.gl20).gl;
        gl.texImage2D(target, 0, GL20.GL_RGBA, GL20.GL_RGBA, GL20.GL_UNSIGNED_BYTE, imageElement);
        if (useMipMaps) {
            gl.generateMipmap(target);
        }
    }

    @Override
    public int getWidth() {
        return imageElement.getWidth();
    }

    @Override
    public int getHeight() {
        return imageElement.getHeight();
    }

    @Override
    public Pixmap.Format getFormat() {
        return format;
    }

    @Override
    public boolean useMipMaps() {
        return useMipMaps;
    }

    @Override
    public boolean isManaged() {
        return false;
    }
}

然后在您的 GWT 项目中的任意位置添加 GwtTextureLoader.java

package com.example.mygame.gwt;

import com.badlogic.gdx.assets.AssetDescriptor;
import com.badlogic.gdx.assets.AssetManager;
import com.badlogic.gdx.assets.loaders.AsynchronousAssetLoader;
import com.badlogic.gdx.assets.loaders.FileHandleResolver;
import com.badlogic.gdx.assets.loaders.TextureLoader;
import com.badlogic.gdx.backends.gwt.GwtFileHandle;
import com.badlogic.gdx.backends.gwt.ImageTextureData;
import com.badlogic.gdx.files.FileHandle;
import com.badlogic.gdx.graphics.Pixmap;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.TextureData;
import com.badlogic.gdx.utils.Array;
import com.google.gwt.dom.client.ImageElement;

public class GwtTextureLoader extends AsynchronousAssetLoader<Texture, TextureLoader.TextureParameter> {
    TextureData data;
    Texture texture;

    public GwtTextureLoader(FileHandleResolver resolver) {
        super(resolver);
    }

    @Override
    public void loadAsync(AssetManager manager, String fileName, FileHandle fileHandle, TextureLoader.TextureParameter parameter) {
        if (parameter == null || parameter.textureData == null) {
            Pixmap.Format format = null;
            boolean genMipMaps = false;
            texture = null;

            if (parameter != null) {
                format = parameter.format;
                genMipMaps = parameter.genMipMaps;
                texture = parameter.texture;
            }

            // Mostly these few lines changed w.r.t. TextureLoader:
            GwtFileHandle gwtFileHandle = (GwtFileHandle) fileHandle;
            ImageElement imageElement = gwtFileHandle.preloader.images.get(fileHandle.path());
            data = new ImageTextureData(imageElement, format, genMipMaps);
        } else {
            data = parameter.textureData;
            if (!data.isPrepared()) data.prepare();
            texture = parameter.texture;
        }
    }

    @Override
    public Texture loadSync(AssetManager manager, String fileName, FileHandle fileHandle, TextureLoader.TextureParameter parameter) {
        Texture texture = this.texture;
        if (texture != null) {
            texture.load(data);
        } else {
            texture = new Texture(data);
        }
        if (parameter != null) {
            texture.setFilter(parameter.minFilter, parameter.magFilter);
            texture.setWrap(parameter.wrapU, parameter.wrapV);
        }
        return texture;
    }

    @Override
    public Array<AssetDescriptor> getDependencies(String fileName, FileHandle fileHandle, TextureLoader.TextureParameter parameter) {
        return null;
    }
}

然后仅在您的 GWT 项目中的 AssetManager 上设置加载程序:

assetManager.setLoader(Texture.class, new GwtTextureLoader(assetManager.getFileHandleResolver()));

注意:你必须确保你的图片是二的幂;这种方法显然不能为你做任何转换。不过应该支持 Mipmapping 和纹理过滤选项。

如果 libGDX 在仅加载图像的常见情况下停止使用 canvas2d 并直接将图像元素传递给 texImage2D 就好了。我不确定如何在架构上适应它(而且我是 GWT 新手)。由于 original GitHub issue is closed, I've filed a new one 与建议的解决方案。

更新:问题已在 this commit 中修复,包含在 libGDX 1.9.4 及更高版本中。