Java软光栅渲染器-光栅化2D点

目标

光栅化2D点(就是在二维数组上画点,了解色彩的基本原理,并解决图像的输出问题)。

  1. 定义颜色(ColorRGBA)类
  2. 定义图像(Image)类
  3. 创建光栅器,实现在数组上画点。
  4. 把图像输出到窗口

实现

定义颜色类

创建ColorRGBA类,用来表达一种颜色。每种颜色有red、green、blue、alpha四个通道,每个通道使用1字节存储,共4字节,正好可以用一个int来表示。

package net.jmecn.math;

/**
 * 颜色类。
 * 每种颜色有red、green、blue、alpha四个通道,每个通道使用1字节存储。
 * 
 * @author yanmaoyuan
 *
 */
public class ColorRGBA {

    public byte r;
    public byte g;
    public byte b;
    public byte a;

    public static final ColorRGBA WHITE = new ColorRGBA(0xFFFFFFFF);
    public static final ColorRGBA BLACK = new ColorRGBA(0x000000FF);
    public static final ColorRGBA RED = new ColorRGBA(0xFF0000FF);
    public static final ColorRGBA GREEN = new ColorRGBA(0x00FF00FF);
    public static final ColorRGBA BLUE = new ColorRGBA(0x0000FFFF);
    public static final ColorRGBA DARKGRAY = new ColorRGBA(0x666666FF);

    public ColorRGBA() {
        r = g = b = a = (byte) 0xFF;
    }

    public ColorRGBA(int color) {
        r = (byte) ((color >> 24) & 0xFF);
        g = (byte) ((color >> 16) & 0xFF);
        b = (byte) ((color >> 8) & 0xFF);
        a = (byte) (color & 0xFF);
    }
}

我把 ColorRGBA 类归于 net.jmecn.math 包,主要是学习 jMonkeyEngine 的做法。因为 ColorRGBA 类可能也会有 add、subtract、mult、divde等运算。

定义图像类

既然每个像素需要4个字节,那么一幅宽为 width 、高为 height 的图像,就需要一个长度为 width * height * 4 的 byte 数组来存储了。Image 类的定义如下:

package net.jmecn.renderer;

/**
 * 用于保存图像数据。 图像格式采用4BYTE_RGBA。
 * 
 * @author yanmaoyuan
 *
 */
public class Image {

    // 图片的宽度
    protected final int width;
    // 图片的高度
    protected final int height;
    // 颜色数据
    protected final byte[] components;

    public Image(int width, int height) {
        this.width = width;
        this.height = height;
        this.components = new byte[width * height * 4];
    }

    public int getWidth() {
        return width;
    }

    public int getHeight() {
        return height;
    }

    public byte[] getComponents() {
        return components;
    }

}

在很多其他的类库中,这个Image类也被称作Bitmap,一般用于表示内存中的位图。它的作用和 BufferedImage 类很相似,只是简化了许多。

光栅器

终于到光栅器了。

package net.jmecn.renderer;

import net.jmecn.math.ColorRGBA;

/**
 * 光栅器,用于绘制基本形状。
 * 
 * @author yanmaoyuan
 *
 */
public class ImageRaster {

    private int width;
    private int height;
    private byte[] components;

    public ImageRaster(Image image) {
        this.width = image.getWidth();
        this.height = image.getHeight();
        this.components = image.getComponents();
    }

    /**
     * 纯色填充
     * 
     * @param color
     */
    public void fill(ColorRGBA color) {
        int length = width * height;
        for (int i = 0; i < length; i++) {
            int index = i * 4;
            
            // 使用一个判断,避免无谓的赋值。
            if (components[index] != color.r || components[index + 1] != color.g
                    || components[index + 2] != color.b || components[index + 3] != color.a) {
                components[index] = color.r;
                components[index + 1] = color.g;
                components[index + 2] = color.b;
                components[index + 3] = color.a;
            }
        }
    }

    /**
     * 画点
     * 
     * @param x
     * @param y
     * @param color
     */
    public void drawPixel(int x, int y, ColorRGBA color) {
        if (x < 0 || y < 0 || x >= width || y >= height) {
            return;
        }

        int index = (x + y * width) * 4;

        components[index] = color.r;
        components[index + 1] = color.g;
        components[index + 2] = color.b;
        components[index + 3] = color.a;
    }
}

ImageRaster 的主要作用是把各种图形光栅化,也就是像素化。画点、画线、画三角形是它最基本的功能。我实现的 ImageRaster 类需要一个 Image 来初始化。它保存了对 Image 中 components 数组的引用,主要是为了方便绘图。

渲染器

现在我们有了ColorRGBA、Image、ImageRaster,已经可以开始画图大业了。这时候就需要一个渲染器来使用它们。

package net.jmecn.renderer;

import net.jmecn.math.ColorRGBA;

/**
 * 渲染器
 * @author yanmaoyuan
 *
 */
public class Renderer {

    // 渲染图像
    private Image renderContext;
    // 光栅器
    private ImageRaster imageRaster;
    // 清屏颜色
    private ColorRGBA clearColor = ColorRGBA.WHITE;
    
    /**
     * 初始化渲染器
     * @param width
     * @param height
     */
    public Renderer(int width, int height) {
        renderContext = new Image(width, height);
        imageRaster = new ImageRaster(renderContext);
    }

    /**
     * 设置背景色
     * @param color
     */
    public void setBackgroundColor(ColorRGBA color) {
        if (color != null) {
            this.clearColor = color;
        }
    }
    
    /**
     * 使用背景色填充图像数据
     */
    public void clear() {
        imageRaster.fill(clearColor);
    }

    /**
     * 获得渲染好的图像
     * @return
     */
    public Image getRenderContext() {
        return renderContext;
    }

    /**
     * 获得光栅器
     * @return
     */
    public ImageRaster getImageRaster() {
        return imageRaster;
    }
}

这个 Renderer 非常简陋,它的主要作用就是保存 Image 和 ImageRaster。使用时先调用 clear() 清屏,然后通过 ImageRaster 画图。渲染好的图像可以通过 getRenderContext() 获得。

现在修改一下 Application,增加一个Renderer类型的私有成员,并在start()方法中实例化。

// 渲染器
private Renderer renderer;

/**
 * 启动程序
 */
public void start() {
    // 计时器
    long startTime = System.nanoTime();
    long previousTime = System.nanoTime();
    long deltaTime;
    float delta;

    // 创建主窗口
    screen = new Screen(width, height, title);
    
    // 创建渲染器
    renderer = new Renderer(width, height);       // <---------
    renderer.setBackgroundColor(ColorRGBA.BLACK); // <---------
    
    // 初始化
    initialize();

    while (isRunning) {
        // ...
        
    }

    // ...
}

注意,在上面的代码中,我省略了start()方法内的很多代码,只突出实例化 renderer 的两条语句。

继续修改 Application 类的 render 方法,把渲染器用起来。

/**
 * 绘制场景
 * 
 * @param delta
 */
protected void render(float delta) {
    // 清空场景
    renderer.clear();

    // 绘制一个方块
    ImageRaster raster = renderer.getImageRaster();
    for(int y=100; y<200; y++) {
        for(int x = 100; x<200; x++) {
            raster.drawPixel(x, y, ColorRGBA.RED);
        }
    }
    
    // 交换画布缓冲区,显示画面
    screen.swapBuffer(framePerSecond);
}

把图像输出到窗口

遗憾的是,我们现在还看不到使用ImageRaster画出来的东西。下面我们要把Image中的图像刷到画布上。

先修改一下Screen 类的 swapBuffer() 方法,让它多接收 Image 对象作为参数。

/**
 * 交换缓冲区,将渲染结果刷新到画布上。
 * @param image
 * @param fps
 */
public void swapBuffer(Image image, int fps) {
    // 略..
}

然后就可以在 render方法中,把渲染好的图像传给Screen。

/**
 * 绘制场景
 * 
 * @param delta
 */
protected void render(float delta) {
    // 清空场景..

    // 绘制一个方块..
    
    // 交换画布缓冲区,显示画面
    screen.swapBuffer(renderer.getRenderContext(), framePerSecond);
}

不过现在画面还是显示不出来。因为Canvas只认识AWT的Image,不认识我定义的这个Image类。我需要直接操作BufferedImage中的像素数据,把渲染的图像拷贝过去。

继续给 Screen 类动手术。先添加一个私有成员,用来存储 BufferedImage 中的像素数据。

private byte[] displayComponents;

然后,在Screen类的构造方法末尾,初始化这个数组。

public Screen(int width, int height, String title) {
    canvas = new Canvas();
    
    // 设置画布的尺寸
    Dimension size = new Dimension(width, height);
    canvas.setPreferredSize(size);
    canvas.setMaximumSize(size);
    canvas.setMinimumSize(size);
    canvas.setFocusable(true);

    // 创建主窗口
    frame = new JFrame();
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    frame.setResizable(false);
    frame.setSize(width, height);
    frame.setTitle(title);
    frame.add(canvas);// 设置画布
    frame.pack();
    frame.setVisible(true);
    centerScreen();// 窗口居中
    
    // 焦点集中到画布上,响应用户输入。
    canvas.requestFocus();
    
    // 创建双缓冲
    canvas.createBufferStrategy(2);
    bufferStrategy = canvas.getBufferStrategy();
    
    // 创建缓冲图像
    displayImage = new BufferedImage(width, height, BufferedImage.TYPE_3BYTE_BGR);
    // 获得图像中的数组
    displayComponents = ((DataBufferByte)displayImage.getRaster().getDataBuffer()).getData();

}

最重要的是最后一句代码。这里使用AWT原生的光栅器,把BufferedImage中的图像数据提取了出来。

displayComponents = ((DataBufferByte)displayImage.getRaster().getDataBuffer()).getData();

需要注意的是,displayComponents 的结构与创建 BufferedImage 对象时的第三个参数 BufferedImage.TYPE_3BYTE_BGR 密切相关。它表示图像中的每个像素使用3个byte来表示,分别为Blue、Green、Red通道。

下面修改swapBuffer方法,把 Image 中的数据拷贝到 displayComponents 里。

/**
 * 交换缓冲区,将渲染结果刷新到画布上。
 * @param image
 * @param fps
 */
public void swapBuffer(Image image, int fps) {
    // 把渲染好的图像拷贝到BufferedImage中。
    int width = image.getWidth();
    int height = image.getHeight();
    byte[] components = image.getComponents();
    int length = width * height;
    for (int i = 0; i < length; i++) {
        // blue
        displayComponents[i * 3] = components[i * 4 + 2];
        // green
        displayComponents[i * 3 + 1] = components[i * 4 + 1];
        // red
        displayComponents[i * 3 + 2] = components[i * 4];
    }
    
    Graphics graphics = bufferStrategy.getDrawGraphics();
    
    // 将BufferedImage绘制到缓冲区
    graphics.drawImage(displayImage, 0, 0, displayImage.getWidth(), displayImage.getHeight(), null);
    
    // 显示帧率
    graphics.setColor(Color.WHITE);
    graphics.drawString("FPS:" + fps, 2, 16);
    
    graphics.dispose();
    
    // 显示图像
    bufferStrategy.show();
}

终于搞完了,运行 Main 类,看看结果吧!

改进

可以预见,在接下来的开发中,会绘制越来越复杂的图形。我不希望每次都重写一遍 render 方法中的绘图代码。所以我得对现在的代码做一点改进。

首先创建 net.jmecn.geom 包,然后创建一个 Drawable 接口。

package net.jmecn.geom;

import net.jmecn.renderer.ImageRaster;

/**
 * 代表一个可渲染物体。
 * 
 * @author yanmaoyuan
 *
 */
public interface Drawable {

    public void draw(ImageRaster imageRaster);
    
}

net.jmecn.geom 包用于定义各种几何形状,它们各自实现 Drawable 接口,使用 ImageRaster 把自己画在 Image 上。

对于画2D点来说,可以这样定义一个Point2D类。

package net.jmecn.geom;
import net.jmecn.math.ColorRGBA;
import net.jmecn.renderer.ImageRaster;

/**
 * 代表一个2D点。
 * @author yanmaoyuan
 *
 */
public class Point2D implements Drawable{

    public int x, y;
    public ColorRGBA color;
    
    public void draw(ImageRaster raster) {
        raster.drawPixel(x, y, color);
    }
}

再来改进一下 Application 类。

定义一个Drawalbe列表,代表“场景”,用来存储所有需要渲染的几何物体。在构造方法中初始化这个列表。

// 渲染队列
protected List<Drawable> scene;

/**
 * 构造方法
 */
public Application() {
    width = 800;
    height = 600;
    title = "JSoftwareRenderer";
    
    // 初始化渲染队列
    scene = new ArrayList<Drawable>(); // <------

    // 改变运行状态
    isRunning = true;
    
    // 关闭固定帧率
    setFrameRate(0);
}

修改render方法,遍历场景中的物体,逐一渲染它们。这样就可以在render方法中渲染任意几何体了。

/**
 * 绘制场景
 * 
 * @param delta
 */
protected void render(float delta) {
    // 清空场景
    renderer.clear();
    
    // 绘制场景
    int len = scene.size();
    if (len > 0) {
        for (int i = 0; i < len; i++) {
            scene.get(i).draw(renderer.getImageRaster());
        }
    }
    
    // 交换画布缓冲区,显示画面
    screen.swapBuffer(renderer.getRenderContext(), framePerSecond);
}

把 initialize() 和 update() 方法改成抽象方法,Application 改成抽象类。

/**
 * 应用程序主类
 * 
 * @author yanmaoyuan
 */
public abstract class Application {

    // 其他代码省略...    

    /**
     * 初始化
     */
    protected abstract void initialize();

    /**
     * 更新场景
     * 
     * @param delta
     */
    protected abstract void update(float delta);

    // 其他代码省略...    
}

这时候我们的Main类开始报错了,因为抽象类不能实例化。现在Application充当了“引擎”,它的子类只需要在 initialize() 中创建几何体,然后添加到场景中,窗口就可以渲染出图像了。

我们让Main继承Application,然后改写一下main方法。

package net.jmecn;

import net.jmecn.geom.Point2D;
import net.jmecn.math.ColorRGBA;

/**
 * 程序运行入口
 * 
 * @author yanmaoyuan
 *
 */
public class Main extends Application {

    public static void main(String[] args) {
        Main app = new Main();
        app.setResolution(720, 405);
        app.setTitle("Java软光栅渲染器 - Main");
        app.setFrameRate(120);
        app.start();
    }

    @Override
    protected void initialize() {
        // 粉色
        ColorRGBA pink = new ColorRGBA(0xFF00FFFF);

        // 画一个方块
        for (int y = 100; y < 200; y++) {
            for (int x = 100; x < 200; x++) {
                Point2D point = new Point2D();
                point.x = x;
                point.y = y;
                point.color = pink;

                // 添加到场景中
                scene.add(point);
            }
        }
    }

    @Override
    protected void update(float delta) {
        // TODO Auto-generated method stub
    }
}

运行结果如下:

其他例子

经过上一步改造,接下来写测试代码就简单多了。创建 net.jmecn.exmaples 包,在这个包中创建 Test2DPoints 类。

package net.jmecn.examples;

import java.util.Random;

import net.jmecn.Application;
import net.jmecn.geom.Point2D;
import net.jmecn.math.ColorRGBA;

/**
 * 绘制2D点
 * 
 * @author yanmaoyuan
 *
 */
public class Test2DPoints extends Application {

    public static void main(String[] args) {
        Test2DPoints app = new Test2DPoints();
        app.setResolution(1080, 720);
        app.setTitle("2D Points");
        app.setFrameRate(120);
        app.start();
    }

    /**
     * 初始化
     */
    @Override
    protected void initialize() {
        Random rand = new Random();
        /**
         * 随机生成点
         */
        for(int i=0; i<1000; i++) {
            Point2D point = new Point2D();
            point.x = rand.nextInt(width);
            point.y = rand.nextInt(height);
            point.color = new ColorRGBA(rand.nextInt(0x4FFFFFFF));
            
            // 添加到场景中
            scene.add(point);
        }
    }

    @Override
    protected void update(float delta) {
    }

}

这个例子随机生成了1000个不同颜色的2D点,并为它们随机产生了颜色。

总结

目标超额达成。不仅完成了光栅化2D点,还对Application进行了重构,可以基于这个抽象类来编写许多不同的小程序了。