怎样用Java制作基本的3D引擎

电子说

1.3w人已加入

描述

步骤1:主类

首先要做的是被造是一个主要的阶级。主类将处理向用户显示图像,调用其他类以重新计算应向播放器显示的内容以及更新相机的位置。

对于该类,导入将是:

import java.awt.Color;

import java.awt.Graphics;

import java.awt.image.BufferStrategy;

import java.awt.image.BufferedImage;

import java.awt.image.DataBufferInt;

import java.util.ArrayList;

import javax.swing.JFrame;

该类及其变量将如下所示:

public class Game extends JFrame implements Runnable{

private static final long serialVersionUID = 1L;

public int mapWidth = 15;

public int mapHeight = 15;

private Thread thread;

private boolean running;

private BufferedImage image;

public int[] pixels;

public static int[][] map =

{

{1,1,1,1,1,1,1,1,2,2,2,2,2,2,2},

{1,0,0,0,0,0,0,0,2,0,0,0,0,0,2},

{1,0,3,3,3,3,3,0,0,0,0,0,0,0,2},

{1,0,3,0,0,0,3,0,2,0,0,0,0,0,2},

{1,0,3,0,0,0,3,0,2,2,2,0,2,2,2},

{1,0,3,0,0,0,3,0,2,0,0,0,0,0,2},

{1,0,3,3,0,3,3,0,2,0,0,0,0,0,2},

{1,0,0,0,0,0,0,0,2,0,0,0,0,0,2},

{1,1,1,1,1,1,1,1,4,4,4,0,4,4,4},

{1,0,0,0,0,0,1,4,0,0,0,0,0,0,4},

{1,0,0,0,0,0,1,4,0,0,0,0,0,0,4},

{1,0,0,2,0,0,1,4,0,3,3,3,3,0,4},

{1,0,0,0,0,0,1,4,0,3,3,3,3,0,4},

{1,0,0,0,0,0,0,0,0,0,0,0,0,0,4},

{1,1,1,1,1,1,1,4,4,4,4,4,4,4,4}

};

请注意,可以将地图重新配置为所需的内容,我在这里只是一个样品。地图上的数字表示该位置的墙壁类型。 0代表空白空间,而其他任何数字则代表实心墙和随之而来的纹理。 BufferedImage是显示给用户的,像素是图像中所有像素的数组。其他变量实际上不会再次出现,它们只是用来使图形和程序正常工作。

构造函数现在看起来像这样:

public Game() {

thread = new Thread(this);

image = new BufferedImage(640, 480, BufferedImage.TYPE_INT_RGB);

pixels = ((DataBufferInt)image.getRaster().getDataBuffer()).getData();

setSize(640, 480);

setResizable(false);

setTitle(“3D Engine”);

setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

setBackground(Color.black);

setLocationRelativeTo(null);

setVisible(true);

start();

}

大多数只是类变量和框架的初始化。 “ pixels =“之后的代码连接像素和图像,以便每当更改像素中的数据值时,向用户显示图像时就会在图像上显示相应的更改。

start和stop方法是简单并用于确保程序安全地开始和结束。

private synchronized void start() {

running = true;

thread.start();

}

public synchronized void stop() {

running = false;

try {

thread.join();

} catch(InterruptedException e) {

e.printStackTrace();

}

}

Game类中需要的最后两个方法是render和run方法。渲染方法将如下所示:

public void render() {

BufferStrategy bs = getBufferStrategy();

if(bs == null) {

createBufferStrategy(3);

return;

}

Graphics g = bs.getDrawGraphics();

g.drawImage(image, 0, 0, image.getWidth(), image.getHeight(), null);

bs.show();

}

渲染时使用缓冲策略,以使屏幕更新更加流畅。总体而言,使用缓冲策略只会帮助游戏在运行时看起来更好。为了将图像实际绘制到屏幕上,需要从缓冲策略中获取图形对象并用于绘制图像。

run方法非常重要,因为它可以处理程序不同部分的更新频率。为此,它使用一些代码来跟踪何时经过了1/60秒,以及何时更新了屏幕和摄像机。这样可以提高程序运行的流畅度。 run方法如下所示:

public void run() {

long lastTime = System.nanoTime();

final double ns = 1000000000.0 / 60.0;//60 times per second

double delta = 0;

requestFocus();

while(running) {

long now = System.nanoTime();

delta = delta + ((now-lastTime) / ns);

lastTime = now;

while (delta 》= 1)//Make sure update is only happening 60 times a second

{

//handles all of the logic restricted time

delta--;

}

render();//displays to the screen unrestricted time

}

}

一旦所有这些方法,构造函数和变量都在其中,那么当前在Game类中剩下要做的唯一事情就是添加一个main方法。主要方法非常简单,您要做的就是:

public static void main(String [] args) {

Game game = new Game();

}

现在,主类已完成!如果您现在运行该程序,则将弹出黑屏。

步骤2:纹理类

在进入查找屏幕外观的计算之前,我将绕行并设置Texture类。纹理将应用于环境中的各种墙壁,并将来自保存在项目文件夹中的图像。在图像中,我包含了在网上找到的4个纹理,将在该项目中使用。您可以使用任何想要的纹理。要使用这些纹理,我建议将它们放在项目文件中的文件夹中。为此,请转到项目文件夹(在Eclipse中,它位于工作区文件夹中)。转到项目文件夹后,创建一个名为“ res”或其他名称的新文件夹。将纹理放在此文件夹中。您可以将纹理放置在其他地方,这就是我存储纹理的地方。完成此操作后,我们就可以开始编写代码以使纹理可用。

该类的导入为:

import java.awt.image.BufferedImage;

import java.io.File;

import java.io.IOException;

import javax.imageio.ImageIO;

该类头及其变量将看起来像这样:

public class Texture {

public int[] pixels;

private String loc;

public final int SIZE;

数组像素用于保存纹理图像中所有像素的数据。 Loc用于向计算机指示可以找到纹理的图像文件的位置。 SIZE是一侧的纹理大小(64x64图像的大小为64),并且所有纹理将完全为正方形。

构造函数将初始化loc和SIZE变量并调用a方法来将图像数据加载到像素中。看起来像这样:

public Texture(String location, int size) {

loc = location;

SIZE = size;

pixels = new int[SIZE * SIZE];

load();

}

现在,Texture类剩下的就是添加一个load方法来从图像中获取数据并将它们存储在像素数据数组中。此方法将如下所示:

private void load() {

try {

BufferedImage image = ImageIO.read(new File(loc));

int w = image.getWidth();

int h = image.getHeight();

image.getRGB(0, 0, w, h, pixels, 0, w);

} catch (IOException e) {

e.printStackTrace();

}

}

load方法的工作原理是从loc指向的文件中读取数据并将该数据写入缓冲的图像。然后,从缓冲的图像中获取每个像素的数据,并将其存储在像素中。

此时,Texture类已完成,因此我将继续定义一些将要使用的纹理在最终程序中。为此,请将此

public static Texture wood = new Texture(“res/wood.png”, 64);

public static Texture brick = new Texture(“res/redbrick.png”, 64);

public static Texture bluestone = new Texture(“res/bluestone.png”, 64);

public static Texture stone = new Texture(“res/greystone.png”, 64);

放在“公共类Texture”行和“ public int []像素”之间。

使其余部分可以访问这些纹理该程序让我们继续前进,并将其交给Game类。为此,我们将需要一个ArrayList来容纳所有纹理,并且需要将纹理添加到此ArrayList中。要创建ArrayList,请将以下代码行和变量放在类的顶部附近:

public ArrayList textures;

此ArrayList必须在构造函数中初始化,并且还应添加纹理在构造函数中。在构造函数中添加以下代码:

textures = new ArrayList();

textures.add(Texture.wood);

textures.add(Texture.brick);

textures.add(Texture.bluestone);

textures.add(Texture.stone);

现在可以使用纹理了!

第3步:相机类

现在让我们绕道而行并设置Camera类。 Camera类跟踪玩家在2D地图中的位置,并负责更新玩家的位置。为此,该类将实现KeyListener,因此将需要导入KeyEvent和KeyListener。

import java.awt.event.KeyEvent;

import java.awt.event.KeyListener;

需要许多变量来跟踪摄像机的位置及其所见。因此,该类的第一个块如下所示:

public class Camera implements KeyListener {

public double xPos, yPos, xDir, yDir, xPlane, yPlane;

public boolean left, right, forward, back;

public final double MOVE_SPEED = .08;

public final double ROTATION_SPEED = .045;

xPos和yPos是玩家在Game类中创建的2D地图上的位置。 xDir和yDir是指向玩家所面对方向的向量的x和y分量。 xPlane和yPlane也是向量的x和y分量。 xPlane和yPlane定义的向量始终垂直于方向向量,并且在一侧指向相机视场的最远边缘。另一边最远的边缘就是负平面向量。方向矢量和平面矢量的组合定义了相机视场中的内容。布尔值用于跟踪用户按下了哪些键,以便用户可以移动相机。 MOVE_SPEED和ROTATION_SPEED指示在用户按下相应键时相机移动和旋转的速度。

接下来是构造函数。构造函数接受告诉类的位置的值,并将相机分配给相应的变量(xPos,yPos 。..)。

public Camera(double x, double y, double xd, double yd, double xp, double yp)

{

xPos = x;

yPos = y;

xDir = xd;

yDir = yd;

xPlane = xp;

yPlane = yp;

}

相机对象在最终程序中将需要它,所以让我们继续添加一个。在具有所有其他变量声明的Game类中,添加

public Camera camera;

,并在构造函数中添加

camera = new Camera(4.5, 4.5, 1, 0, 0, -.66);

addKeyListener(camera);

,此摄像机将与地图一起使用我正在使用,如果您使用的是其他地图,或者想从其他位置开始,请调整xPos和yPos的值(在我的示例中为4和6)。使用.66可以提供良好的视野,但是您可以调整该值以获得不同的FOV。

现在,Camera类具有构造函数,我们可以开始添加方法来跟踪用户的输入并更新相机的位置/方向。因为Camera类实现了KeyboardListener,所以它必须具有其实现的所有方法。 Eclipse应该自动提示您添加这些方法。您可以将keyTyped方法保留为空白,但将使用其他两种方法。当按下相应的键时,keyPressed会将布尔值设置为true,而释放键时,keyReleased会将其更改为false。方法看起来像这样:

public void keyPressed(KeyEvent key) {

if((key.getKeyCode() == KeyEvent.VK_LEFT))

left = true;

if((key.getKeyCode() == KeyEvent.VK_RIGHT))

right = true;

if((key.getKeyCode() == KeyEvent.VK_UP))

forward = true;

if((key.getKeyCode() == KeyEvent.VK_DOWN))

back = true;

}

public void keyReleased(KeyEvent key) {

if((key.getKeyCode() == KeyEvent.VK_LEFT))

left = false;

if((key.getKeyCode() == KeyEvent.VK_RIGHT))

right = false;

if((key.getKeyCode() == KeyEvent.VK_UP))

forward = false;

if((key.getKeyCode() == KeyEvent.VK_DOWN))

back = false;

}

现在,Camera类正在跟踪按下了哪些键,我们可以开始更新播放器的位置。为此,我们将使用在Game类的run方法中调用的update方法。在此过程中,我们将继续进行操作,并通过在Game类中将地图传递给update方法时,将冲突检测添加到update方法中。更新方法如下所示:

public void update(int[][] map) {

if(forward) {

if(map[(int)(xPos + xDir * MOVE_SPEED)][(int)yPos] == 0) {

xPos+=xDir*MOVE_SPEED;

}

if(map[(int)xPos][(int)(yPos + yDir * MOVE_SPEED)] ==0)

yPos+=yDir*MOVE_SPEED;

}

if(back) {

if(map[(int)(xPos - xDir * MOVE_SPEED)][(int)yPos] == 0)

xPos-=xDir*MOVE_SPEED;

if(map[(int)xPos][(int)(yPos - yDir * MOVE_SPEED)]==0)

yPos-=yDir*MOVE_SPEED;

}

if(right) {

double oldxDir=xDir;

xDir=xDir*Math.cos(-ROTATION_SPEED) - yDir*Math.sin(-ROTATION_SPEED);

yDir=oldxDir*Math.sin(-ROTATION_SPEED) + yDir*Math.cos(-ROTATION_SPEED);

double oldxPlane = xPlane;

xPlane=xPlane*Math.cos(-ROTATION_SPEED) - yPlane*Math.sin(-ROTATION_SPEED);

yPlane=oldxPlane*Math.sin(-ROTATION_SPEED) + yPlane*Math.cos(-ROTATION_SPEED);

}

if(left) {

double oldxDir=xDir;

xDir=xDir*Math.cos(ROTATION_SPEED) - yDir*Math.sin(ROTATION_SPEED);

yDir=oldxDir*Math.sin(ROTATION_SPEED) + yDir*Math.cos(ROTATION_SPEED);

double oldxPlane = xPlane;

xPlane=xPlane*Math.cos(ROTATION_SPEED) - yPlane*Math.sin(ROTATION_SPEED);

yPlane=oldxPlane*Math.sin(ROTATION_SPEED) + yPlane*Math.cos(ROTATION_SPEED);

}

}

该方法中控制前进和后退运动的部分通过分别向xPos和yPos添加xDir和yDir来工作。在此动作发生之前,程序会检查该动作是否会将相机放置在墙内,如果可以,则不进行检查。对于旋转,方向矢量和平面矢量都乘以旋转矩阵,即:

[ cos(ROTATION_SPEED) -sin(ROTATION_SPEED) ]

[ sin(ROTATION_SPEED) cos(ROTATION_SPEED) ]

以获得其新值。完成update方法后,我们现在可以从Game类中调用它。在Game类的run方法中,添加以下代码行,在此处显示

Add this:

camera.update(map);

in here:

while(running) {

long now = System.nanoTime();

delta = delta + ((now-lastTime) / ns);

lastTime = now;

while (delta 》= 1)//Make sure update is only happening 60 times a second

{

//handles all of the logic restricted time

camera.update(map);

delta--;

}

render();//displays to the screen unrestricted time

}

现在,完成了,我们终于可以进入最终类并计算屏幕了!

第4步:计算屏幕

在Screen类中,大部分的计算都是为了使程序正常工作。要工作,该类需要以下导入:

import java.util.ArrayList;

import java.awt.Color;

实际的类是这样开始的:

public class Screen {

public int[][] map;

public int mapWidth, mapHeight, width, height;

public ArrayList textures;

该地图与在游戏类。屏幕使用它来确定墙壁在哪里以及与玩家之间的距离。宽度和高度定义屏幕的大小,并且应始终与Game类中创建的框架的宽度和高度相同。纹理是所有纹理的列表,以便屏幕可以访问纹理的像素。在声明了这些变量之后,必须像下面这样在构造函数中对其进行初始化:

public Screen(int[][] m, ArrayList tex, int w, int h) {

map = m;

textures = tex;

width = w;

height = h;

}

现在是时候编写类具有的一个方法了:update方法。更新方法根据用户在地图中的位置重新计算屏幕的外观。该方法被不断调用,并将更新后的像素数组返回给Game类。该方法开始于“清除”屏幕。通过将上半部分的所有像素设置为一种颜色,并将下半部分的所有像素设置为另一种颜色来实现此目的。

public int[] update(Camera camera, int[] pixels) {

for(int n=0; n if(pixels[n] != Color.DARK_GRAY.getRGB()) pixels[n] = Color.DARK_GRAY.getRGB();

}

for(int i=pixels.length/2; i if(pixels[i] != Color.gray.getRGB()) pixels[i] = Color.gray.getRGB();

}

让屏幕的顶部和底部为两个不同颜色也使它看起来好像有地板和天花板。清除像素阵列后,该是进行主要计算的时候了。该程序循环遍历屏幕上的每个垂直条,并投射光线以找出该垂直条上的屏幕上应该有什么墙。循环的开始看起来像这样:

for(int x=0; xdouble cameraX = 2 * x / (double)(width) -1;

double rayDirX = camera.xDir + camera.xPlane * cameraX;

double rayDirY = camera.yDir + camera.yPlane * cameraX;

//Map position

int mapX = (int)camera.xPos;

int mapY = (int)camera.yPos;

//length of ray from current position to next x or y-side

double sideDistX;

double sideDistY;

//Length of ray from one side to next in map

double deltaDistX = Math.sqrt(1 + (rayDirY*rayDirY) / (rayDirX*rayDirX));

double deltaDistY = Math.sqrt(1 + (rayDirX*rayDirX) / (rayDirY*rayDirY));

double perpWallDist;

//Direction to go in x and y

int stepX, stepY;

boolean hit = false;//was a wall hit

int side=0;//was the wall vertical or horizontal

这里发生的所有事情都是计算出循环其余部分将要使用的一些变量。 CameraX是摄影机平面上当前垂直条纹的x坐标,并且rayDir变量为射线创建矢量。计算所有以DistX或DistY结尾的变量,以便程序仅在可能发生碰撞的位置检查碰撞。 perpWallDist是从播放器到射线与之碰撞的第一堵墙的距离。这将在以后计算。完成此操作后,我们需要根据已经计算出的变量来找出其他一些变量。

//Figure out the step direction and initial distance to a side

if (rayDirX 《 0)

{

stepX = -1;

sideDistX = (camera.xPos - mapX) * deltaDistX;

}

else

{

stepX = 1;

sideDistX = (mapX + 1.0 - camera.xPos) * deltaDistX;

}

if (rayDirY 《 0)

{

stepY = -1;

sideDistY = (camera.yPos - mapY) * deltaDistY;

}

else

{

stepY = 1;

sideDistY = (mapY + 1.0 - camera.yPos) * deltaDistY;

}

一旦完成,就该找出射线与何处碰撞了。一堵墙。为此,程序要经过一个循环,在该循环中检查射线是否与墙壁接触,如果没有,则移动到下一个可能的碰撞点,然后再次检查。

//Loop to find where the ray hits a wall

while(!hit) {

//Jump to next square

if (sideDistX 《 sideDistY)

{

sideDistX += deltaDistX;

mapX += stepX;

side = 0;

}

else

{

sideDistY += deltaDistY;

mapY += stepY;

side = 1;

}

//Check if ray has hit a wall

if(map[mapX][mapY] 》 0) hit = true;

}

现在我们知道射线在何处撞击墙壁,我们可以开始计算墙壁在我们当前所在的垂直条纹中的外观。为此,我们首先计算到墙的距离,然后使用该距离来计算出墙在垂直条中应该有多高。然后,我们根据屏幕上的像素将该高度转换为起点和终点。代码如下所示:

//Calculate distance to the point of impact

if(side==0)

perpWallDist = Math.abs((mapX - camera.xPos + (1 - stepX) / 2) / rayDirX);

else

perpWallDist = Math.abs((mapY - camera.yPos + (1 - stepY) / 2) / rayDirY);

//Now calculate the height of the wall based on the distance from the camera

int lineHeight;

if(perpWallDist 》 0) lineHeight = Math.abs((int)(height / perpWallDist));

else lineHeight = height;

//calculate lowest and highest pixel to fill in current stripe

int drawStart = -lineHeight/2+ height/2;

if(drawStart 《 0)

drawStart = 0;

int drawEnd = lineHeight/2 + height/2;

if(drawEnd 》= height)

drawEnd = height - 1;

计算完之后,就该开始从墙的纹理中找出哪些像素会真正呈现给用户了。为此,我们首先必须确定与刚击中的墙关联的纹理,然后确定将向用户显示的像素的纹理的x坐标。

//add a texture

int texNum = map[mapX][mapY] - 1;

double wallX;//Exact position of where wall was hit

if(side==1) {//If its a y-axis wall

wallX = (camera.xPos + ((mapY - camera.yPos + (1 - stepY) / 2) / rayDirY) * rayDirX);

} else {//X-axis wall

wallX = (camera.yPos + ((mapX - camera.xPos + (1 - stepX) / 2) / rayDirX) * rayDirY);

}

wallX-=Math.floor(wallX);

//x coordinate on the texture

int texX = (int)(wallX * (textures.get(texNum).SIZE));

if(side == 0 && rayDirX 》 0) texX = textures.get(texNum).SIZE - texX - 1;

if(side == 1 && rayDirY 《 0) texX = textures.get(texNum).SIZE - texX - 1;

通过获取在2D地图上击中墙壁的确切位置并减去整数值(仅保留小数)来计算x坐标。然后将此小数(wallX)乘以墙的纹理大小即可在我们希望绘制的像素的墙上获得确切的x坐标。一旦我们知道剩下要做的就是计算纹理上像素的y坐标并将其绘制在屏幕上。为此,我们遍历垂直条带中屏幕上的所有像素,然后对其进行计算并计算纹理上像素的确切y坐标。然后,使用该程序,程序将纹理中像素的数据写入屏幕上的像素阵列。该程序还使此处的水平墙比垂直墙暗,以提供基本的照明效果。

//calculate y coordinate on texture

for(int y=drawStart; y int texY = (((y*2 - height + lineHeight) 《《 6) / lineHeight) / 2;

int color;

if(side==0) color = textures.get(texNum).pixels[texX + (texY * textures.get(texNum).SIZE)];

else color = (textures.get(texNum).pixels[texX + (texY * textures.get(texNum).SIZE)]》》1) & 8355711;//Make y sides darker

pixels[x + y*(width)] = color;

}

然后,Screen类中剩下的就是返回像素数组

return pixels;

该类完成。现在,我们要做的就是在Game类中添加几行代码以使屏幕正常运行。在变量顶部添加以下内容:

public Screen screen;

,然后在构造函数中,在初始化纹理之后,将其添加到某处。

screen = new Screen(map, mapWidth, mapHeight, textures, 640, 480);

最后,在在camera.update(map)之前运行运行方法add

screen.update(camera, pixels);

。程序就完成了!

步骤5:最终代码

责任编辑:wv

打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分