Java软光栅渲染器-透明度

目标

OpenGL中,混合(Blending)通常是实现物体透明度(Transparency)的一种技术。透明就是说一个物体(或者其中的一部分)不是纯色(Solid Color)的,它的颜色是物体本身的颜色和它背后其它物体的颜色的不同强度结合。一个有色玻璃窗是一个透明的物体,玻璃有它自己的颜色,但它最终的颜色还包含了玻璃之后所有物体的颜色。这也是混合这一名字的出处,我们混合(Blend)(不同物体的)多种颜色为一种颜色。所以透明度能让我们看穿物体。

透明的物体可以是完全透明的(让所有的颜色穿过),或者是半透明的(它让颜色通过,同时也会显示自身的颜色)。一个物体的透明度是通过它颜色的aplha值来决定的。Alpha颜色值是颜色向量的第四个分量,你可能已经看到过它很多遍了。在这个教程之前我们都将这个第四个分量设置为1.0,让这个物体的透明度为0.0,而当alpha值为0.0时物体将会是完全透明的。当alpha值为0.5时,物体的颜色有50%是来自物体自身的颜色,50%来自背后物体的颜色。

上文摘自:LearnOpenGL中文版 混合

本文的目标是:

  • 实现Alpha测试
  • 实现颜色混合

Alpha测试很容易理解,并不需要特别的参考资料。下面主要是关于BlendMode的参考资料:

实现

Alpha测试和颜色混合都发生在光栅化的最后阶段。实现这两个功能,需要在SoftwareRaster类的rasterizePixel中增加一些代码。

Alpha测试

Alpha测试的逻辑是:若是一个像素完全透明,就应该舍弃这个它,避免把物体背后的其他颜色挡住。

开启Alpha测试的效果:

关闭Alpha测试的效果:

Alpha测试的参数

在 RenderState 类中增加 2个参数,用来控制Alpha测试。

  • boolean isAplhaTest 这是Alpha测试的开关,默认值为false
  • float alphaFalloff 这是透明度的阈值,若颜色的alpha值低于它,将被认为是完全透明的。

RenderState中增加的代码如下:

private boolean isAlphaTest;
private float alphaFalloff;

public RenderState() {
    // ...

    isAlphaTest = false;
    alphaFalloff = 0f;

    // ...

}

public boolean isAlphaTest() {
    return isAlphaTest;
}

public void setAlphaTest(boolean isAlphaTest) {
    this.isAlphaTest = isAlphaTest;
}

public float getAlphaFalloff() {
    return alphaFalloff;
}

public void setAlphaFalloff(float alphaFalloff) {
    this.alphaFalloff = alphaFalloff;
}

在光栅化阶段进行Alpha测试

在SoftwareRaster中增加Alpha测试。

/**
 * 光栅化点
 * @param x
 * @param y
 * @param frag
 */
public void rasterizePixel(int x, int y, RasterizationVertex frag) {

    if (x < 0 || y < 0 || x >= width || y >= height) {
        return;
    }

    // 执行片段着色器
    fragmentShader(frag);

    int index = x + y * width;
    float depth = frag.position.z / frag.position.w;

    // 深度测试
    if (renderState.isDepthTest()) {
        if (!depthTest(depthBuffer[index], depth))
        return;
    }

    // Alpha测试
    if (renderState.isAlphaTest()) {
        if (frag.color.w < renderState.getAlphaFalloff())
            return;
    }

    // TODO 颜色混合

    Vector4f destColor = frag.color;
    destColor.x = clamp(destColor.x, 0, 1);
    destColor.y = clamp(destColor.y, 0, 1);
    destColor.z = clamp(destColor.z, 0, 1);
    destColor.w = clamp(destColor.w, 0, 1);

    // 写入depthBuffer
    if (renderState.isDepthWrite()) {
        depthBuffer[index] = depth;
    }

    // 写入frameBuffer
    index *= 4;

    components[index] = (byte)(destColor.x * 0xFF);
    components[index + 1] = (byte)(destColor.y * 0xFF);
    components[index + 2] = (byte)(destColor.z * 0xFF);
    components[index + 3] = (byte)(destColor.w * 0xFF);
}

测试Alpha通道的作用

新建一个测试用例,感受一下Alpha测试的效果。在这个测试用例中,我使用了默认的Texture对象,它生成了一个黑白相间的纹理,其中黑色区域的Alpha通道值为0。

package net.jmecn.examples;

import net.jmecn.Application;
import net.jmecn.material.Material;
import net.jmecn.material.RenderState.CullMode;
import net.jmecn.material.Texture;
import net.jmecn.math.Vector3f;
import net.jmecn.scene.Geometry;
import net.jmecn.scene.shape.Box;

/**
 * 测试Alpha
 * @author yanmaoyuan
 *
 */
public class Test3DAlphaTest extends Application {

    public static void main(String[] args) {
        Test3DAlphaTest app = new Test3DAlphaTest();
        app.setResolution(400, 300);
        app.setTitle("Test AlphaChannel");
        app.setFrameRate(60);
        app.start();
    }

    @Override
    protected void initialize() {
        // 初始化摄像机
        getCamera().lookAt(new Vector3f(2, 3, 4), Vector3f.ZERO, Vector3f.UNIT_Y);

        // 定义材质
        Material material = new Material();

        // 使用程序纹理
        Texture texture = new Texture();
        material.setTexture(texture);

        // 不裁剪,这样我们就能看到立方体的内部。
        material.getRenderState().setCullMode(CullMode.NEVER);

        // 开启Alpha测试
        material.getRenderState().setAlphaTest(true);
        material.getRenderState().setAlphaFalloff(0.75f);

        // 添加到场景中
        rootNode.attachChild(new Geometry(new Box(), material));
    }

    @Override
    protected void update(float delta) {}

}

运行结果。

把isAlphaTest设为false,或者把alphaFalloff的值设为0,关闭Alpha测试再看看有什么不同。

关于AlphaFalloff

刚开始实现Alpha测试功能时,我有些怀疑AlphaFalloff这个参数的意义:直接把alpha等于0的颜色去掉不就好了吗?

这个问题与纹理采样方式有关。如果是采用NEAREST采样,当然没问题。但如果使用BILINEAR采样,某些像素会跟它旁边的颜色混合在一起,产生平滑的变化。

分别把AlphaFalloff设为0.01f、0.99f和0.75f,对比一下。

当AlphaFalloff为0.01f时:

当AlphaFalloff为0.99f时:

当AlphaFalloff值为0.75f时:

虽然Texture中黑色像素的Alpha为0、白色像素的Alpha为1,但是由于纹理使用了BILINEAR采样,总有一部分像素的Alpha值是介于0.0~1.0之间的。若AlphaFalloff的值接近0f,就会看到“灰边”。若AlphaFalloff的值接近1f,纹理又会被剔除掉过多的像素。

根据实际测试,我选择AlphaFalloff的值为0.75f,只要不凑得太近,是看不出来灰边的。

不过,若是采用NEAREST采样,AlphaFalloff的取值就不太影响Alpha测试的效果了,但是物体表面看起来会有锯齿。

颜色混合

定义BlendMode

在RenderState中,定义BlendMode枚举,默认值为OFF,即关闭颜色混合。

/**
 * 混色模式
 */
public enum BlendMode {
    OFF,        // 实心
    ADD,        // 叠加
    ALPHA_BLEND // ALPHA混合
}

private BlendMode blendMode;

public RenderState() {
    // ...
    blendMode = BlendMode.OFF;
}

public BlendMode getBlendMode() {
    return blendMode;
}

public void setBlendMode(BlendMode blendMode) {
    this.blendMode = blendMode;
}

在光栅化阶段进行颜色混合

修改rasterizePixel方法,在通过深度测试、Alpha测试后,根据BlendMode来计算目标颜色。

/**
 * 光栅化点
 * @param x
 * @param y
 * @param frag
 */
public void rasterizePixel(int x, int y, RasterizationVertex frag) {
    //..
    // 执行片段着色器..
    // 深度测试..
    // Alpha测试..

    // 颜色混和
    Vector4f srcColor = frag.color;
    Vector4f destColor = getColor(x, y);

    switch (renderState.getBlendMode()) {
    case OFF:
        destColor.x = srcColor.x;
        destColor.y = srcColor.y;
        destColor.z = srcColor.z;
        break;
    case ADD:
        destColor.x += srcColor.x;
        destColor.y += srcColor.y;
        destColor.z += srcColor.z;
        break;
    case ALPHA_BLEND:
        destColor.x = destColor.x + (srcColor.x - destColor.x) * srcColor.w;
        destColor.y = destColor.y + (srcColor.y - destColor.y) * srcColor.w;
        destColor.z = destColor.z + (srcColor.z - destColor.z) * srcColor.w;
        break;
    }

    destColor.x = clamp(destColor.x, 0, 1);
    destColor.y = clamp(destColor.y, 0, 1);
    destColor.z = clamp(destColor.z, 0, 1);
    destColor.w = clamp(destColor.w, 0, 1);

    // 写入depthBuffer..
    // 写入frameBuffer..
}

测试BlendMode的作用

写个测试用例,来验证一下BlendMode和AlphaTest作用。

在这之前,我从learnopengl-cn.github.io网站上借用了两个图片素材,放到了工程的res目录下。

下面这个窗户纹理中的玻璃部分的alpha值为0.25(它在一般情况下是完全的红色,但由于它有75%的透明度,能让很大一部分的网站背景颜色穿过,让它看起来不那么红了),边框的alpha值是0.0。它在工程中的路径为:res/blending_transparent_window.png

在下面这个草纹理,要么是完全不透明的(alpha值为1.0),要么是完全透明的(alpha值为0.0),没有中间情况。它在工程中的路径为:res/grass.png

测试代码如下:

package net.jmecn.examples;

import java.io.IOException;

import net.jmecn.Application;
import net.jmecn.material.Material;
import net.jmecn.material.RenderState.BlendMode;
import net.jmecn.material.Texture;
import net.jmecn.math.Vector3f;
import net.jmecn.renderer.Image;
import net.jmecn.scene.Geometry;
import net.jmecn.scene.Mesh;
import net.jmecn.scene.shape.Quad;

/**
 * 测试颜色混合
 * @author yanmaoyuan
 *
 */
public class Test3DBlendMode extends Application {

    public static void main(String[] args) {
        Test3DBlendMode app = new Test3DBlendMode();
        app.setResolution(400, 300);
        app.setTitle("Test BlendMode");
        app.setFrameRate(60);
        app.start();
    }

    private Mesh mesh = new Quad();

    @Override
    protected void initialize() {
        // 初始化摄像机
        getCamera().lookAt(new Vector3f(2, 3, 4), Vector3f.ZERO, Vector3f.UNIT_Y);

        makeOpaque();
        makeTransparent();
        makeTranslucent();
    }

    /**
     * 不透明的物体
     * @return
     */
    private void makeOpaque() {
        Material material = new Material();

        // 使用默认的程序纹理
        Texture texture = new Texture();
        material.setTexture(texture);

        // 关闭Alpha测试
        material.getRenderState().setAlphaTest(false);

        // 关闭颜色混合
        material.getRenderState().setBlendMode(BlendMode.OFF);

        Geometry geom = new Geometry(mesh, material);
        rootNode.attachChild(geom);
    }

    /**
     * 透明物体,应该使用AlphaTest,剔除透明部分。
     * @return
     */
    private void makeTransparent() {
        Material material = new Material();

        try {
            Image image = new Image("res/grass.png");
            Texture texture = new Texture(image);
            material.setTexture(texture);
        } catch (IOException e) {
            e.printStackTrace();
        }

        // 开启Alpha测试
        material.getRenderState().setAlphaTest(true);
        material.getRenderState().setAlphaFalloff(0.75f);

        Geometry geom = new Geometry(mesh, material);
        rootNode.attachChild(geom);

        geom.getLocalTransform().setTranslation(0, 0, 0.2f);
    }

    /**
     * 半透明物体,应该最后被渲染,并且不写深度缓冲。
     * @return
     */
    private void makeTranslucent() {
        Material material = new Material();

        try {
            Image image = new Image("res/blending_transparent_window.png");
            Texture texture = new Texture(image);
            material.setTexture(texture);
        } catch (IOException e) {
            e.printStackTrace();
        }

        // 开启Alpha测试,剔除掉窗户边缘Alpha为0的像素。
        material.getRenderState().setAlphaTest(true);
        material.getRenderState().setAlphaFalloff(0.01f);

        // 设置颜色混合模式
        material.getRenderState().setBlendMode(BlendMode.ALPHA_BLEND);

        // 深度缓冲设为只读模式
        material.getRenderState().setDepthWrite(false);

        Geometry geom = new Geometry(mesh, material);
        rootNode.attachChild(geom);

        geom.getLocalTransform().setTranslation(1, 0, 0.4f);
    }

    @Override
    protected void update(float delta) {}

}

运行效果:

如果不考虑纹理透视修正的问题,这个效果图还是蛮不错的。

注:我稍微改了一下Quad类的顶点颜色,把四个点的颜色都改成了(1, 1, 1, 1),这样就不会影响纹理的颜色了。这个问题本来应该通过着色器解决,不过现在先这么处理吧。

总结

目标达成。

在实际的3D图形引擎中,会根据物体的透明度进行分组、排序。不透明、半透明、完全透明的物体各分一组,半透明物体需要根据离摄像机的距离进行排序。本程序没有想做那么复杂,所以就不实现分组排序了。