安卓游戏开发手册(二)

原文:zh.annas-archive.org/md5/677EF72CE0EEA561393A2FD5106AE241

译者:飞龙

协议:CC BY-NC-SA 4.0

第五章:了解游戏循环和帧率

游戏循环是游戏的运行主体,帧率是其结果。没有定义游戏循环,游戏就无法制作;没有测量帧率,性能就无法判断。

游戏开发的这两个方面在任何游戏开发项目中都是通用的。然而,游戏循环的可伸缩性和性质在不同设备上有所不同,不同平台上可能有不同的帧率测量标准。

对于原生开发,游戏循环由开发人员自行创建和维护。然而,在大多数游戏引擎中,循环已经定义了所有必要的控制和范围。

我们将通过以下主题详细了解游戏开发中最重要的这两个部分:

  • 游戏循环介绍

  • 使用 Android SDK 创建示例游戏循环

  • 游戏生命周期

  • 游戏更新和用户界面

  • 中断处理

  • 游戏状态机的一般概念

  • FPS 系统

  • 硬件依赖

  • 性能和内存之间的平衡

  • 控制 FPS

游戏循环介绍

游戏循环是核心循环,依次执行用户输入、游戏更新和渲染。这个循环理想情况下每帧运行一次。因此,游戏循环是运行帧率控制游戏最重要的部分。

典型的游戏循环有三个步骤:

  1. 用户输入

  2. 游戏更新

  3. 渲染!游戏循环介绍

简单的游戏循环

用户输入

本节检查游戏的 UI 系统是否接收到了外部输入。它设置了游戏在下一次更新时需要进行的更改。在不同的硬件平台上,游戏循环的这部分变化最大。为不同的输入类型创建通用功能是一种最佳实践,以确保标准化。

输入系统不被视为游戏循环的一部分;然而,用户输入检测是游戏循环的一部分。该系统不断监视输入系统,无论是否发生事件。

当活动游戏循环运行时,用户可以在游戏过程中的任何时间触发任何事件。通常,输入系统维护着队列。每个队列代表不同类型的可能输入事件,如触摸、按键、传感器读数等。

用户输入监视器按照循环顺序在特定间隔内检查这些队列。如果在队列中发现任何事件,它会进行必要的更改,这将影响游戏循环中下一次更新的调用:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

用户输入工作原理

游戏更新

游戏循环的游戏更新部分管理和维护完整的游戏状态。该部分还负责运行游戏逻辑、游戏状态的更改、加载/卸载资源以及设置渲染管线。

游戏控制通常由游戏更新部分管理。通常,主游戏管理器在游戏更新部分的顶层工作。我们在前一节讨论了游戏程序结构。

任何游戏一次只能运行一个特定状态。状态可以通过用户输入或任何自动化的 AI 算法进行更新。所有 AI 算法都是逐帧地在游戏更新周期上运行的。

状态更新

如前所述,状态可以从游戏更新中进行更新。状态也是由游戏更新初始化和销毁的。初始化和销毁每个状态只发生一次,状态更新可以在每个游戏周期中调用一次。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

状态更新调用流程

渲染帧

游戏循环中的渲染部分负责设置渲染管线。在游戏循环的这一部分中不会运行任何更新或 AI 算法。

曾经有一段时间,开发者对渲染管线有完全控制。开发者可以操纵并设置每一个顶点。现代游戏开发系统与这种渲染系统没有太多关系。图形库负责渲染系统的所有控制。然而,在非常高的层面上,开发者只能设置渲染顶点的顺序和数量。

在帧速率控制方面,渲染是最重要的角色之一,保持其他连续进程的稳定性。从处理的角度来看,显示和内存操作需要最长的执行时间。

典型的 Android 图形渲染遵循 OpenGL 管线:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

使用 Android SDK 创建一个示例游戏循环

Android SDK 开发从一个活动开始,游戏在单个或多个视图上运行。大多数情况下,考虑使用单个视图来运行游戏。

不幸的是,Android SDK 没有提供预定义的游戏循环。然而,循环可以以许多种方式创建,但基本机制保持不变。

在 Android SDK 库中,View类包含一个抽象方法OnDraw(),其中每个可能的渲染调用都排队等候。这个方法在绘图发生任何改变时被调用,这会使之前的渲染管线无效。

逻辑如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

让我们来看一个使用 Android View创建的基本游戏循环。在这里,一个自定义视图是从 Android View扩展而来的:

/*Sample Loop created within OnDraw()on Canvas 
* This loop works with 2D android game development
*/
@Override
public void onDraw(Canvas canvas)
{
  //If the game loop is active then only update and render
  if(gameRunning)
  {
    //update game state
    MainGameUpdate();

    //set rendering pipeline for updated game state
    RenderFrame(canvas);
    //Invalidate previous frame, so that updated pipeline can be
    // rendered
    //Calling invalidate() causes recall of onDraw()
    invalidate();
  }
  else
  {
    //If there is no active game loop
    //Exit the game
    System.exit(0);
  }
}

在当前的 Android 游戏开发时代,开发者使用SurfaceView而不是ViewSurfaceView继承自View,并且更适用于使用 Canvas 制作的游戏。在这种情况下,一个定制视图是从SurfaceView扩展并实现SurfaceHolder.Callback接口。在这种情况下,重写了三种方法:

/* Called When a surface is changed */
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height)
{
}
/* Called on create of a SurfaceView */
@Override
public void surfaceCreated(SurfaceHolder holder)
{
}
/* Called on destroy of a SurfaceView is destroyed */
@Override
public void surfaceDestroyed(SurfaceHolder holder)
{
}

在开发游戏时,开发者不需要每次更改表面。这就是surfaceChanged方法应该有一个空体来作为基本游戏循环的原因。

我们需要创建一个定制的游戏线程并重写run()方法:

public class BaseGameThread extends Thread
{
  private boolean isGameRunning;
  private SurfaceHolder currentHolder;
  private MyGameState currentState;
  public void activateGameThread(SurfaceHolder holder, MyGameState state)
  {
    currentState = state;
    isGameRunning = true;
    currentHolder = holder;
    this.start();
  }

  @Override
  public void run()
  {
    Canvas gameCanvas = null;
    while(isGameRunning)
    {
      //clear canvas
      gameCanvas = null;
      try
      {
        //locking the canvas for screen pixel editing
        gameCanvas  = currentHolder.lockCanvas();
        //Update game state
        currentState.update();
        //render game state
        currentState.render(gameCanvas);
      }
      catch(Exception e)
      {
        //Update game state without rendering (Optional)
        currentState.update();
      }
    }
  }
}

现在,我们准备从定制的SurfaceView类开始新创建的游戏循环:

public myGameCanvas extends SurfaceView implements SurfaceHolder
{
  //Declare thread
  private BaseGameThread gameThread;
  private MyGameState gameState;
  @Override
  public void surfaceCreated(SurfaceHolder holder)
  {
    //Initialize game state
    gameState = new MyGameState();
    //Instantiate game thread
    gameThread = new BaseGameThread();
    //Start game thread
    gameThread. activateGameThread(this.getHolder(),gameState);
  }

  @Override
  public void surfaceChanged(SurfaceHolder holder, int format, int width, int height)
  {
  }

  @Override
  public void surfaceDestroyed(SurfaceHolder holder)
{
}
}

实现游戏循环可以有很多方法。然而,基本方法遵循这里提到的两种方式之一。一些开发者更喜欢在游戏视图内部实现游戏线程。处理输入是游戏循环的另一个重要部分。我们将在本章后面讨论这个话题。

游戏循环的另一个部分是每秒帧数FPS)管理。最常见的机制之一是使用Thread.sleep()来计算循环以固定速率执行的时间。一些开发者创建了两种更新机制:一种基于 FPS,另一种基于每帧无延迟。

大多数基于物理的游戏需要一个更新机制,以便在所有设备上以统一的实时间隔运行。

对于小规模开发,行业中有一些开发者采用第一种方法,但不遵循典型的循环。这种系统根据所需的动作使当前绘制无效。在这种情况下,游戏循环不依赖于固定的 FPS。

游戏生命周期

Android 游戏生命周期几乎与任何其他应用程序的生命周期相似,除了游戏循环机制。大多数情况下,应用程序状态会随着外部干扰而改变。状态也可以通过其他方式进行操作,游戏具有能够干扰主游戏循环的算法或人工智能。

Android 游戏是通过活动初始化的。onCreate()方法用于初始化。然后,游戏线程启动并进入游戏循环。游戏循环可以被外部中断打断。

在游戏开发中,始终要保存当前游戏状态并正确暂停循环和线程。在恢复游戏时,应该很容易返回到上一个状态。

游戏更新和用户界面

我们已经在之前涵盖了一些更新和接口机制。运行中的游戏状态可以通过用户输入或内部 AI 算法来改变:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

大多数情况下,游戏更新每帧调用一次,或者在固定时间间隔后调用一次。无论哪种方式,算法都会改变游戏状态。您已经了解了用户输入队列。在每个游戏循环周期中,都会检查输入队列。

例如,具有触摸界面的移动游戏循环的工作方式如下:

/* import proper view and implement touch listener */
public class MyGameView extends View implements View.OnTouchListener
/* declare game state */
private MyGameState gameState;
/* set listener */
public MyGameView (Context context)
{
  super(context);
  setOnTouchListener(this);
  setFocusableInTouchMode(true);
  gameState = new MyGameState();
}

/* override onTouch() and call state update on individual touch events */
@Override
public boolean onTouch(View v, MotionEvent event) 
{
  if(event.getAction() == MotionEvent.ACTION_UP)
  {
    //call changes in current state on touch release
    gameState.handleInputTouchRelease((int)event.getX(), (int)event.getY());
    return false;
  }
  else if(event.getAction() == MotionEvent.ACTION_DOWN)
  {
    //call changes in current state on touch begin
    gameState.handleInputTouchEngage((int)event.getX(), (int)event.getY());
  }
  else if(event.getAction() == MotionEvent.ACTION_MOVE)
  {
    //call changes in current state on touch drag
    gameState.handleInputTouchDrag((int)event.getX(), (int)event.getY());
  }
  return true;
}

现在,让我们以相同的方式来看待输入队列系统:

Point touchBegin = null;
Point touchDragged = null;
Point touchEnd = null;

@Override
public boolean onTouch(View v, MotionEvent event)
{
  if(event.getAction() == MotionEvent.ACTION_UP)
  {
    touchEnd = new Point(int)event.getX(), (int)event.getY());
    return false;
  }
  else if(event.getAction() == MotionEvent.ACTION_DOWN)
  {
    touchBegin = new Point(int)event.getX(), (int)event.getY());

  }
  else if(event.getAction() == MotionEvent.ACTION_MOVE)
  {
    touchDragged = new Point(int)event.getX(), (int)event.getY());

  }
  return true;
}

/* declare checking input mechanism */
private void checkUserInput() 
{
  if(touchBegin != null)
  {
    //call changes in current state on touch begin
    gameState. handleInputTouchEngage (touchBegin);
    touchBegin = null;
  }

  if(touchDragged != null)
  {
    //call changes in current state on touch drag
    gameState. handleInputTouchDrag (touchDragged);
    touchDragged = null;
  }

  if(touchEnd != null)
{
    //call changes in current state on touch release  
    gameState.handleInputTouchRelease (touchEnd);
    touchEnd = null;
  }
}

/* finally we need to invoke checking inside game loop */
@Override
public void onDraw(Canvas canvas)
{
  //If the game loop is active then only update and render
  if(gameRunning)
  {
    //check user input
    checkUserInput();
    //update game state
    MainGameUpdate();

    //set rendering pipeline for updated game state
    RenderFrame(canvas);
    //Invalidate previous frame, so that updated pipeline can be
    // rendered
    //Calling invalidate() causes recall of onDraw()
    invalidate();
  }
  else
  {
    //If there is no active game loop
    //Exit the game
    System.exit(0);
  }
}

相同的过程也可以用于SurfaceView游戏循环方法。

中断处理

游戏循环是一个持续的过程。每当发生中断时,有必要暂停每个运行的线程并保存游戏的当前状态,以确保它能够正确恢复。

在 Android 中,任何中断都会触发onPause()

@Override
protected void onPause() 
{
  super.onPause();
  // pause and save game loop here
}
// When control is given back to application, then onResume() is // called.
@Override
protected void onResume() 
{
  super.onResume();
  //resume the game loop here
}

现在,我们需要更改实际运行游戏循环的类。

首先,声明一个布尔值来指示游戏是否暂停。然后,在游戏循环中进行检查。之后,创建一个处理这个变量的静态方法:

private static boolean gamePaused = false;
@Override
public void onDraw(Canvas canvas)
{
  if(gameRunning && ! gamePaused)
  {
    MainGameUpdate();
    RenderFrame(canvas);

    invalidate();
  }
  else if(! gamePaused)
  {
    //If there is no active game loop
    //Exit the game
    System.exit(0);
  }
}

public static void enableGameLoop(boolean enable)
{
  gamePaused = enable;
  if(!gamePaused)
  {
    //invalidation of previous draw has to be called from static
    // instance of current View class
    this.invalidate();
  }
  else
  {
    //save state
  }
}

游戏状态机的一般概念

游戏状态机在游戏循环的更新周期内运行。游戏状态机是将所有游戏状态绑定在一起的机制。在旧的技术中,这是典型的线性控制流。然而,在现代开发过程中,它可以是在多个线程中并行运行的控制流。在游戏开发的旧架构中,鼓励只有一个游戏线程。开发人员过去会避免并行处理,因为它容易受到游戏循环和定时器管理的影响。然而,即使在现代开发中,许多开发人员仍然在可能的情况下更喜欢使用单个线程进行游戏开发。借助各种工具和高级脚本语言的帮助,大多数游戏开发人员现在都使用虚拟并行处理系统。

简单游戏状态机的一个过程是创建一个公共状态接口,并为每个游戏状态重写它。这样,在游戏循环内管理状态就变得容易了。

让我们看一个简单的游戏状态机管理器的循环。该管理器应该执行四个主要功能:

  • 创建状态

  • 更新状态

  • 渲染状态

  • 改变状态

示例实现可能如下所示:

public class MainStateManager
{
  private int currentStateId;
  //setting up state IDs
  public Interface GameStates
  {
    public static final int STATE_1 = 0;
    public static final int STATE_2 = 1;
    public static final int STATE_3 = 2;
    public static final int STATE_4 = 3; 
  }

  private void initializeState(int stateId)
  {
    currentStateId = stateId;
    switch(currentStateId)
    {
      case STATE_1:
        // initialize/load state 1
      break;
      case STATE_2:
        // initialize/load state 2
      break;
      case STATE_3:
        // initialize/load state 3
      break;
      case STATE_4:
        // initialize/load state 4
      break;
    }
  }
}
/*
* update is called in every cycle of game loop.
* make sure that the state is already initialized before updating the state
*/
private void updateState()
{
  switch(currentStateId)
  {
    case STATE_1:
      // Update state 1
    break;
    case STATE_2:
      // Update state 2
    break;
    case STATE_3:
      // Update state 3
    break;
    case STATE_4:
      // Update state 4
    break;
  }
}
/*
* render is called in every cycle of game loop.
* make sure that the state is already initialized before updating the state
*/
private void renderState()
{
  switch(currentStateId)
  {
    case STATE_1:
      // Render state 1
    break;
    case STATE_2:
      // Render state 2
    break;
    case STATE_3:
      // Render state 3
    break;
    case STATE_4:
      // Render state 4
    break;
  }
}
/*
* Change state can be triggered from outside of manager or from any other state
* This should be responsible for destroying previous state and free memory and initialize new state
*/
public void changeState(int nextState)
{
  switch(currentStateId)
  {
    case STATE_1:
      // Destroy state 1
    break;
    case STATE_2:
      // Destroy state 2
    break;
    case STATE_3:
      // Destroy state 3
    break;
    case STATE_4:
      // Destroy state 4
    break;
  }
  initializeState(nextState);
}
}

在某些情况下,开发人员还会通过状态管理器将输入信号传递到特定状态。

FPS 系统

在游戏开发和游戏行业中,FPS 非常重要。游戏质量的衡量取决于 FPS 计数。简单来说,游戏的 FPS 越高,越好。游戏的 FPS 取决于指令和渲染的处理时间。

执行游戏循环需要一些时间。让我们看一下游戏循环内 FPS 管理的示例实现:

long startTime;
long endTime;
public final int TARGET_FPS = 60;

@Override
public void onDraw(Canvas canvas)
{
  if(isRunning)
  {
    startTime = System.currentTimeMillis();
    //update and paint in game cycle
    MainGameUpdate();

    //set rendering pipeline for updated game state
    RenderFrame(canvas);

    endTime = System.currentTimeMillis();
    long delta = endTime - startTime;
    long interval = (1000 - delta)/TARGET_FPS;

    try
    {
      Thread.sleep(interval);
    }
    catch(Exception ex)
    {}
    invalidate();
  }
}

在上面的示例中,我们首先记录了循环执行之前的时间(startTime),然后记录了循环执行之后的时间(endTime)。然后我们计算了执行所需的时间(delta)。我们已经知道应该花费多少时间(interval)来保持最大帧率。因此,在剩余的时间里,我们让游戏线程在再次执行之前进入休眠状态。这也可以应用于不同的游戏循环系统。

在使用SurfaceView时,我们可以在run()方法中的游戏循环内声明 FPS 系统:

long startTime;
long endTime;
public final int TARGET_FPS = 60;
@Override
public void run()
{
  Canvas gameCanvas = null;
  while(isGameRunning)
  {
    startTime = System.currentTimeMillis();
    //clear canvas
    gameCanvas = null;
    try
    {
      //locking the canvas for screen pixel editing
      gameCanvas  = currentHolder.lockCanvas();
      //Update game state
      currentState.update();
      //render game state
      currentState.render(gameCanvas);
      endTime = System.currentTimeMillis();
      long delta = endTime - startTime;
      long interval = (1000 - delta)/TARGET_FPS;

      try
      {
        Thread.sleep(interval);
      }
      catch(Exception ex) 
      {}
    }
    Catch(Exception e)
    {
      //Update game state without rendering (Optional)
      currentState.update();
    }
  }
}

在这个过程中,我们限制了 FPS 计数,并尝试在预定义的 FPS 上执行游戏循环。这个系统的一个主要缺点是这种机制在很大程度上取决于硬件配置。对于无法在预定义的 FPS 上运行循环的慢硬件系统,这个系统没有效果。这是因为间隔时间大多为零或小于零,因此没有每帧周期。

硬件依赖

我们之前讨论过硬件配置在 FPS 系统中起着重要作用。如果硬件无法以特定频率运行一组指令,那么任何开发人员都无法在目标 FPS 上运行游戏。

让我们列出游戏中占用大部分处理时间的任务:

  • 显示或渲染

  • 内存加载/卸载操作

  • 逻辑操作

显示或渲染

显示处理主要取决于图形处理器和需要显示的内容。当涉及与硬件的交互时,处理变慢。使用着色器操作和映射渲染每个像素都需要时间。

曾经有时候以 12 帧的帧率运行游戏是困难的。然而,在现代世界,一个出色的显示质量游戏需要以 60 帧的帧率运行。这只是硬件质量的问题。

大屏幕需要大量的缓存内存。例如,硬件具有大而密集的显示屏,但缓存内存较少,无法保持良好的显示质量。

内存加载/卸载操作

内存是系统的硬件组件。再次强调,与内存组件交互需要更多时间。从开发人员的角度来看,当我们分配内存、释放内存和进行读写操作时,需要时间。

从游戏开发的角度来看,有四种类型的内存最重要:

  • 堆内存

  • 堆栈内存

  • 寄存器内存

  • 只读存储器(ROM)

堆内存

堆内存是用户手动管理的内存。这个内存必须手动分配和手动释放。在 Android 的情况下,垃圾收集器负责释放被标记为非引用的内存。这个内存位置是随机存取内存类别中最慢的。

堆栈内存

这段内存用于在方法内声明的元素。程序解释器会自动分配和释放这个内存段。这个内存段仅适用于本地成员。

寄存器内存

寄存器内存是最快的。寄存器内存用于存储当前进程和频繁使用的数据。在寄存器内存更好和更快的设备上,游戏开发人员可以实现更高的帧率。

只读存储器(ROM)

只读存储器(ROM)是永久存储器。特别是在游戏开发中,大量的资源存储在 ROM 中。在加载/卸载这些资源时需要最长的时间。程序需要从 ROM 中将必要的数据加载到 RAM 中。因此,拥有更快的 ROM 有助于在加载/卸载操作期间获得更好的 FPS。

逻辑操作

开发人员应该以最有效的方式定义指令,以便利用硬件。从技术角度来看,每条指令以二进制指令形式进入堆栈。处理器在一个时钟周期内执行一条指令。

例如,让我们看一个构造不好的逻辑指令:

char[] name = "my name is android";
for(int i = 0; i < name.length; i ++)
{
  //some operation
}

每次调用length并使用后增量运算符都会增加处理器的指令,最终增加执行时间。现在,看看这段代码:

char[] name = "my name is android";
int length = name.length;
for(int i = 0; i < length; ++ i)
{
  //some operation
}

这段代码执行了相同的任务;然而,在这种方法中,处理开销大大减少了。这段代码所做的唯一妥协是阻塞了一个整数变量的内存,并保存了与length相关的许多嵌套任务。

时钟速度更快的处理器可以更快地执行任务,这直接意味着更好的 FPS。然而,任务量的管理取决于开发人员,就像前面的例子所示。

每个处理器都有一个数学处理单元。处理器的性能因处理器而异。因此,开发人员总是需要检查数学表达式,以了解它是否可以简化。

性能和内存之间的平衡

正如您之前学到的,内存操作需要很长时间。然而,开发人员总是有限的内存。因此,在性能和内存之间保持平衡是非常必要的。

从 ROM 加载或卸载任何资产到 RAM 都需要时间,因此建议您不要为依赖 FPS 的游戏执行此类操作。这种操作会显著影响 FPS。

假设一个游戏在运行一个游戏状态时需要大量资产,而目标设备可用的堆有限。在这种情况下,开发人员应该对资产进行分组。只有在必要的情况下,才能在运行状态的游戏中加载小资产。

有时,许多开发人员预加载所有资产并从缓存中使用。这种方法使游戏玩起来更加流畅和快速。然而,如果在不需要的特定游戏状态下加载缓存中的资产,可能会在发生中断时导致游戏崩溃。Android 操作系统完全有权清除不活动或最小化应用程序所占用的内存。当发生中断时,游戏进入最小化状态。如果新应用程序需要内存而没有空闲内存可用,那么 Android 操作系统会终止不活动的应用程序并为新应用程序释放内存。

因此,根据游戏状态,将资产集分成部分总是一个很好的做法。

控制 FPS

我们已经看到了一些定义 FPS 系统的方法。我们也已经讨论了该系统的主要缺点。因此,我们可以根据当前游戏循环周期中生成的实时 FPS 来操纵游戏循环:

long startTime;
long endTime;
public static in ACTUAL_FPS = 0;

@Override
public void onDraw(Canvas canvas)
{
  if(isRunning)
  {
    startTime = System.currentTimeMillis();
    //update and paint in game cycle
    MainGameUpdate();

    //set rendering pipeline for updated game state
    RenderFrame(canvas);

    endTime = System.currentTimeMillis();
    long delta = endTime - startTime;
    ACTUAL_FPS = 1000 / delta;
    invalidate();
  }
}

现在,让我们来看一下混合 FPS 系统,我们将最大 FPS 限制为 60。否则,游戏可能会通过实际 FPS 进行操纵:

long startTime;
long endTime;
public final int TARGET_FPS = 60;
public static int ACTUAL_FPS = 0;

@Override
public void onDraw(Canvas canvas)
{
  if(isRunning)
  {
    startTime = System.currentTimeMillis();
    //update and paint in game cycle
    MainGameUpdate();

    //set rendering pipeline for updated game state
    RenderFrame(canvas);

    endTime = System.currentTimeMillis();
    long delta = endTime - startTime;

    //hybrid system begins
    if(delta < 1000)
    {
      long interval = (1000 - delta)/TARGET_FPS;
      ACTUAL_FPS = TARGET_FPS;
      try
      {
        Thread.sleep(interval);
      }
      catch(Exception ex) 
      {}
    }
    else
    {
      ACTUAL_FPS = 1000 / delta;
    }
    invalidate();
  }
}

总结

游戏循环主要是游戏开发的逻辑方法。在许多情况下,开发人员不选择这种机制。有些游戏可能是典型的互动游戏,没有连续运行的算法。在这种情况下,可能不需要游戏循环。游戏状态可以根据输入更新到游戏系统。

然而,例外不能成为例证。这就是为什么遵循游戏循环以保持开发标准是一种工业方法,而不管游戏设计如何。

您在这里了解了游戏循环和游戏状态管理。开发人员可以自由地以不同的方式发明和执行游戏循环。许多游戏引擎具有不同的控制游戏循环和管理游戏状态的方式。游戏循环和状态管理的理念和概念可能会根据游戏需求而改变。

然而,开发人员应始终牢记,他们使用的技术不应影响游戏性能和 FPS。此外,开发人员需要保持代码的可读性和灵活性。某些方法可能会消耗更多内存并运行得更快,反之亦然。Android 具有各种硬件配置,因此可能不是所有硬件上都具有相同的处理和内存支持。最终,在内存和性能之间取得平衡是创造更好游戏的关键。

我们将在后面的章节中深入研究性能和内存管理。我们将尝试从不同的角度来看待游戏开发的这些部分,比如 2D/3D 游戏、VR 游戏、优化技术等。

第六章:提高 2D/3D 游戏性能

曾几何时,移动平台上的游戏局限于黑白像素游戏,其他游戏媒介也严重依赖像素图形。现在时代已经改变。3D 游戏在手持设备上轻松运行。然而,对 2D 资产的需求尚未改变。即使在 hardcore 3D 游戏中,2D 资产也是必需的。很少有游戏是完全 2D 的。

我们将在以下主题的帮助下讨论 2D 和 3D 游戏的性能:

  • 2D 游戏开发的限制

  • 3D 游戏开发的限制

  • Android 的渲染管道

  • 通过 OpenGL 渲染

  • 优化 2D 资产

  • 优化 3D 资产

  • 常见的游戏开发错误

  • 2D/3D 性能比较

2D 游戏开发的限制

从 2D 游戏开发的角度来看,主要的限制如下:

  • 2D 艺术资产

  • 2D 渲染系统

  • 2D 映射

  • 2D 物理

2D 艺术资产

艺术资产的限制主要限于图形或视觉资产,包括图像、精灵和字体。不难理解,较大的资产将比较小的资产需要更多的时间来处理和渲染,从而导致性能质量较低。

一组 2D 艺术资产

在 Android 游戏开发中,不可能通过一组资产提供最大的显示质量。这是大多数 Android 游戏开发人员选择高分辨率资产作为基础构建的原因。这通常对高配置硬件平台表现良好,但在低配置设备上无法提供良好的性能。许多开发人员选择为多分辨率硬件平台进行移植的选项。这再次需要时间来完成项目。

多分辨率使用相同的资产集

许多时候,开发人员选择忽略一组硬件平台。在移动游戏行业中,选择更高分辨率的艺术资产并将其缩小适应较低分辨率设备是一种常见做法。如今,大多数硬件平台都有更好的 RAM。因此,这个过程对开发人员来说变得更加方便。

屏幕上绘制的资产数量

游戏性能并不总是取决于资产大小;它还取决于屏幕上绘制的资产数量。精灵表的概念已经发展,以减少屏幕上绘制元素的数量。

通常,系统会为单个艺术资产发出绘制指令。随着资产数量的增加,需要更多这样的绘制指令才能在每个游戏循环周期内完成渲染。显然,这个过程会减慢处理器的速度,游戏性能变差。

精灵表可以包含单个图像中的多个资产。因此,只需要一个绘制指令就可以渲染精灵的所有资产。然而,精灵表的物理大小是受限制的。不同硬件平台的设备的最大尺寸各不相同。最方便的是,1024x1024 的精灵是目前几乎所有可用设备支持的最安全选项。

使用字体文件

几乎每个游戏都使用除了 Android 默认系统字体之外的自定义或特殊字体。在这些情况下,字体源文件必须包含在游戏构建中。有多种使用不同字体的方法。我们将在这里讨论其中三种:

  • 精灵字体

  • 位图字体

  • TrueType 字体

精灵字体

这是一种典型的老派技术,但在某些情况下仍然有效。开发人员创建一个包含所有必要字符的精灵表。所有字符都在数据文件中映射。这种映射用于裁剪每个字符并相应地形成单词。

这种字体的一些优点包括:

  • 开发人员完全控制映射

  • 字符风格可以根据需求定制

  • 可以实现快速处理速度;但这将取决于开发效率

这种字体的一些缺点包括:

  • 它们增加了开发开销

  • 系统效率完全取决于开发人员的技能

  • 在多语言支持的情况下很难映射字符

  • 任何更改都需要大量迭代才能达到完美

这种样式现在通常不再使用,因为我们有许多设计师和时尚字体可用。

位图字体

位图字体系统是从精灵字体继承而来的。它更新了预定义的映射样式和支持开发过程的库。它还使用一个或多个精灵表和一个数据文件。位图字体的工作原理与精灵字体相同。有很多工具可以直接从 TrueType 字体创建这样的字体,并进行一些样式化。

这种字体的一些优点包括:

  • 它与任何现有的代码库兼容,无论渲染框架是 OpenGL、DirectX、Direct Draw 还是 GDI+

  • 易于集成

  • 它可以操纵现有 TrueType 字体的样式

这种字体的一些缺点包括:

  • 精灵字体的相同缺点在这里也适用,只是开发开销更小

  • 放大位图字体会导致模糊的输出

TrueType 字体

这是大多数平台支持的通用字体格式,包括 Android。这是在游戏中集成各种字体的最快速的方法。

这种字体的一些优点包括:

  • 通用字体样式

  • 最大的平台支持

  • 易于多语言实现

  • 这是一种矢量字体,所以它没有缩放问题

  • 易于获得特殊字符

这种字体的一些缺点包括:

  • 使用这种字体样式可能会使游戏多花费几千字节

  • 并非所有脚本语言都受 TTF 支持

2D 渲染系统

Android 提供了通过 API 框架将 2D 资产渲染到画布的范围。画布可以与ViewSurfaceView中的Drawable对象一起使用。

画布充当实际绘图表面的接口,所有的图形对象都可以在其上绘制。在onDraw()回调方法中绘制画布上的图形对象。开发人员只需指定图形对象及其在画布上的位置。

画布本身具有一组默认的绘图方法,可以渲染几乎每种类型的图形对象。以下是一些例子:

  • drawBitmap()方法用于以位图格式绘制图像对象。但是,图像不需要以位图格式。

  • drawRect()drawLine()方法用于在画布上绘制原始形状。

  • drawText()方法可用于使用特定字体样式在画布上渲染文本。

画布可以在 Android 架构中的视图中使用。

2D 映射

2D 映射基于简单的 2D 坐标系。唯一的区别是与传统坐标系相比相反的y轴:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在 Android 2D 中,原点位于画布的左上角。所有的几何计算都是基于这种映射的。然而,它对性能没有直接影响,就像基于 2D 画布的应用程序一样。许多开发人员习惯于根据传统系统映射他们的图形资产,并颠倒垂直轴以在画布上渲染它。这需要一些额外的计算。

关于 2D 渲染系统还有一个性能约束。全球通用的开发方法是拥有一组最小的图形资产,并尽可能多地使用它们。经常会导致同一像素多次渲染。这会影响处理速度,从而影响 FPS。

例如,位图A、位图B、位图C和位图D以一种使ABC重叠在一起,而D保持独立的方式在画布上呈现。以下情况发生:

  • 在只绘制一个位图的区域R0中的像素将被渲染一次

  • 在两个位图重叠的区域R1中的像素将被渲染两次

  • 在区域R2中,三个位图重叠的像素将被渲染三次

这在这里显示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

现在,在区域R1R2,所有像素都被渲染多次。在这个系统中,像素数据信息将附加到先前的数据上,导致最终像素值。在这个系统中,处理开销增加。因此,性能下降。

即使在今天,这仍然是 2D 游戏编程的常见做法。原因如下:

  • 透明度混合

  • 模块化图形资源

  • 低构建大小

  • 通过叠加多个资源轻松构建屏幕

有时,可能会出现设备的图形处理器性能非常低,多次渲染同一个像素对性能有很大影响。在这种情况下,双缓冲机制会有很大帮助。

双缓冲系统是指创建一个缓冲的可显示资源,使用图形资源创建显示屏,然后只在屏幕上绘制一次这个缓冲对象。它可以防止以下问题:

  • 屏幕闪烁

  • 一个像素的多次绘制

  • 资源的撕裂

2D 物理

2D 物理只考虑x-y平面上的所有计算。市场上有很多 2D 物理引擎可用。Box2D是最流行的一个。物理引擎包括实时物理的每一个机制和计算。

实时物理计算比游戏所需的要复杂得多。让我们讨论一下一些可用的物理引擎。

Box2D

Box2D 是一个基于 C++的开源物理引擎。它包括几乎可以用于各种游戏的固体物理的每一个方面。它的一些值得一提的特性如下:

  • 刚体的动态碰撞检测

  • 碰撞状态回调,如碰撞进入、退出、停留等

  • 多边形碰撞

  • 垂直、水平和抛射运动

  • 摩擦物理

  • 扭矩和动量物理

  • 基于枢轴点和关节的重力效应

LiquidFun

LiquidFun 是一个液体物理的物理引擎。这个引擎实际上是基于 Box2D 的。谷歌发布了这个开源物理引擎来覆盖液体物理的公式和机制。LiquidFun 可以用于 Android、iOS、Windows 和其他一些流行的平台。LiquidFun 支持 Box2D 的每一个特性,以及液体粒子物理。这包括以下内容:

  • 波浪模拟

  • 液体下落和粒子模拟

  • 液体搅拌模拟

  • 固体和液体动态碰撞

  • 液体混合

对游戏性能的影响

碰撞检测是一个昂贵的过程。多边缘和多边形碰撞会增加处理开销。刚体和碰撞表面的数量对性能有最大的影响。这就是为什么液体物理比固体物理慢的原因。

让我们来看一下主要影响:

  • 任何刚体的每次变换都需要刷新整个系统的碰撞检查

  • 物理引擎负责重复的变换,这导致了繁重的过程

物理引擎计算刚体上的每一个可能的力。并不是所有的游戏都需要进行每一项计算。游戏开发并不总是需要实时实现物理。然而,游戏需要实时可视化。

2D 碰撞检测

大多数游戏使用盒子碰撞系统来检测大多数碰撞。矩形碰撞检测是最便宜的方法,可以在游戏内用于检测碰撞。

有时,三角形和圆形碰撞检测也用于 2D 游戏以提高碰撞检测的准确性。需要在使用这些方法之间取得平衡。

例如,如果我们需要检测两个圆之间的碰撞,我们可以选择以下任何一个系统:

  • 将每个圆视为矩形并检测它们之间的碰撞

  • 将一个圆视为矩形,并检测圆和矩形之间的碰撞

  • 应用实际的圆形碰撞检测方法

让我们考虑两个圆,它们的原点分别是O1O2,直径分别是R1R2

O1位于*(Ox1, Oy1)*

O2位于*(Ox2, Oy2)*

矩形碰撞

如果我们想象圆在 2D 画布上是矩形,那么它会是这样的:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

矩形碰撞检测指的是这个公式。

输入反馈将如下:

xMin1 = x1(第一个矩形在x轴上的最小坐标)

yMin1 = y1(第一个矩形在y轴上的最小坐标)

xMax1 = x1m(第一个矩形在x轴上的最大坐标)

yMax1 = y1m(第一个矩形在y轴上的最大坐标)

xMin2 = x2(第二个矩形在x轴上的最小坐标)

yMin2 = y2(第二个矩形在y轴上的最小坐标)

xMax2 = x2m(第二个矩形在x轴上的最大坐标)

yMax2 = y2m(第二个矩形在y轴上的最大坐标)

在给定的情况下,我们将有以下情况:

x1 = Ox1 – (R1 / 2)

y1 = Oy1 – (R1 / 2)

x1m = Ox1 + (R1 / 2) = x1 + R1

y1m = Oy1 + (R1 / 2) = y1 + R1

x2 = Ox2 – (R2 / 2)

y2 = Oy2 – (R2 / 2)

x2m = Ox2 + (R2 / 2) = x2 + R2

y2m = Oy2 + (R2 / 2) = y2 + R2

这两个矩形是否碰撞的条件如下:

if( x1m < x2 )
{
  // Not Collide
}
else if( y1m < y2 )
{
  // Not collide
}
else if( x1 > x2m )
{
  //Not collide
}
else if( y1 > y2m )
{
  //Not collide
}
else
{
  //Successfully collide 
}

矩形和圆的碰撞

现在,只考虑第二个圆作为矩形,我们会得到这样:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

由于我们已经讨论了相同系统的坐标系的一般概念,我们可以直接推导出数值:

Px1 = Ox2 – (R2 / 2)

Py1 = Oy2 – (R2 / 2)

Px2 = Ox2 – (R2 / 2)

Py2 = Oy2 + (R2 / 2)

Px3 = Ox2 + (R2 / 2)

Py3 = Oy2 + (R2 / 2)

Px4 = Ox2 + (R2 / 2)

Py4 = Oy2 – (R2 / 2)

x2m = Ox2 + (R2 / 2) = x2 + R2

y2m = Oy2 + (R2 / 2) = y2 + R2

radius1 = (R1 / 2)

distanceP1 = squareRoot(((Px1 – Ox1) (Px1 – Ox1)) + ((Py1 – Oy1)* (Py1 – Oy1)))*

distanceP2 = squareRoot(((Px2 – Ox1) (Px2 – Ox1)) + ((Py2 – Oy1)* (Py2 – Oy1)))*

distanceP3 = squareRoot(((Px3 – Ox1) (Px3 – Ox1)) + ((Py3 – Oy1)* (Py3 – Oy1)))*

distanceP4 = squareRoot(((Px4 – Ox1) (Px4 – Ox1)) + ((Py4 – Oy1)* (Py4 – Oy1)))*

碰撞和非碰撞条件如下:

if ( (Ox1 + radius1) < x2 )
{
  //Not collide
}
else if ( Ox1 > x2m )
{
  //Not collide
}
else if ( (Oy1 + radius1) < y2 )
{
  //Not collide
}
else if ( Oy1 > y2m )
{
  //Not collide
}
else 
{
if (distanceP1 <= radius1)
{
  //Successfully collide
}
else if (distanceP2 <= radius1)
{
  //Successfully collide
}
else if (distanceP3 <= radius1)
{
  //Successfully collide
}
else if (distanceP4 <= radius1)
{
  //Successfully collide
}
else if ( Ox1 >= Px1 && Ox1 <= x2m &&
(Oy1 + radius1) >= Py1 && (Oy1 <= y2m))
{
  //Successfully collide
}
else if ( Oy1 >= Py1 && Oy1 <= y2m &&
(Ox1 + radius1) >= Px1 && (Ox1 <= x2m))
{
  //Successfully collide
}
else
{
  //Not collide
}

圆和圆的碰撞

最后,实际的碰撞检测系统是圆和圆的碰撞:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

从逻辑上讲,这是找出圆形碰撞的最简单的方法。

首先,计算两个圆的原点之间的距离:

originDistance = squareRoot ( ((Ox2 – Ox1) (Ox2 – Ox1)) + ((Ox2 – Ox1)* (Ox2 – Ox1)))*

现在,我们需要检查距离是否小于或等于两个圆的半径之和:

if (originDistance <= ((R1 + R2) / 2))
{
  //Successfully Collide
}
else
{
  //Not Collide
}

性能比较

对于第一种方法,执行检查需要最少的时钟周期。然而,它并不那么准确。特别是当开发人员使用更大的圆时,缺乏准确性就会变得明显。

第三种方法是完全准确的,但处理时间更长。在运行时有许多圆相互碰撞的情况下,这个过程和数学计算可能会导致性能延迟。

总体而言,第二种方法是解决这个问题的最糟糕的方式。然而,在非常特定的情况下,可以使用这种方法。当开发人员想要准确检测圆和矩形的碰撞时,只有这种方法才能尝试。

检测这种类型的碰撞可能有多种解决方案。在性能方面,您在这里学到的方法和解决方案是最有效的几种解决方案之一。

在准确检测矩形和圆形碰撞时,还有一种更流行的方法,即通过增加圆的直径来创建一个更大的圆角矩形,增加宽度和高度。这个过程更重,但更准确。

3D 游戏开发的限制

Android 本地的 3D 游戏开发非常复杂。Android 框架不支持直接的 3D 游戏开发平台。Android Canvas 直接支持 2D 游戏开发。开发人员需要 OpenGL 支持才能为 Android 开发 3D 游戏。

开发受到 Android NDK 的支持,它基于 C++。我们将讨论一下在 Android 上使用 OpenGL 支持的 3D 开发的一些限制。

Android 提供了 OpenGL 库进行开发。开发人员需要首先设置场景、光线和摄像机才能开始任何开发过程。

顶点和三角形

顶点指的是 3D 空间中的一个点。在 Android 中,Vector3可以用来定义顶点。三角形由三个这样的顶点组成。任何三角形都可以投影到一个 2D 平面上。任何 3D 对象都可以简化为围绕其表面的三角形集合。

例如,一个立方体表面是两个三角形的集合。因此,一个立方体可以由 12 个三角形组成,因为它有六个表面。三角形的数量对渲染时间有很大影响。

3D 变换矩阵

每个 3D 对象都有自己的变换。Vector可以用来指示其位置、缩放和旋转。通常,这通过一个称为变换矩阵的矩阵来表示。变换矩阵的维度为 4 x 4。

让我们假设矩阵为T

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这里:

  • *{a, b, c, e, f, g, i, j, k}*代表线性变换

  • *{d, h, l}*代表透视变换

  • {m, n, o}代表绕xyz轴的平移

  • {a, f, k}代表沿xyz轴的局部缩放

  • *{p}*代表整体缩放

  • {f, g, i, k}代表绕x轴的旋转,其中a = 1

  • {a, c, i, k}代表绕y轴的旋转,其中f = 1

  • {a, b, e, f}代表绕z轴的旋转,其中k = 1

任何 3D 对象都可以使用这个矩阵和相应的变换 3D 向量进行平移。自然地,矩阵计算比 2D 简单线性计算更重。随着顶点数量的增加,计算数量也会增加。这会导致性能下降。

3D 对象和多边形数量

任何 3D 模型或对象都有被称为多边形的表面。较少的多边形意味着较少的三角形,这直接减少了顶点数量:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这是一个简单的多边形分布的 3D 对象表面的例子。一个六边形有四个三角形和六个顶点。每个顶点都是一个 3D 向量。每个处理器都需要时间来处理每个顶点。建议您检查每个绘制周期中将绘制的总多边形数量。许多游戏因多边形数量过高和无法管理而遭受显著的 FPS 下降。

Android 是专门针对移动操作系统的。大多数情况下,它具有有限的设备配置。通常,管理 Android 上 3D 游戏的多边形数量成为开发人员的问题。

3D 渲染系统

Android 使用 OpenGL 提供了一个 3D 渲染平台,包括框架和 NDK。Android 框架提供了GLSurfaceViewGLSurfaceView.Renderer来渲染 Android 中的 3D 对象。它们负责在屏幕上生成模型。我们已经通过 OpenGL 讨论了 3D 渲染管线。

3D 渲染将所有对象映射到一个 3D 世界坐标系,遵循右手拇指系统:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

3D 网格

3D 网格是由顶点、三角形和表面创建的。网格用于确定物体的形状。纹理被应用到网格上以创建完整的模型。

创建网格是 3D 模型创建中最棘手的部分,因为基本的优化可以在这里应用。

以下是创建网格的过程:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

一个 3D 模型可以包含多个网格,它们甚至可以互换。网格负责模型的细节质量和渲染性能。对于 Android 开发,建议对网格的顶点和三角形数量保持一定限制,以提高渲染性能。

材料、着色器和纹理

在通过网格形成模型结构后,纹理被应用于其上,以创建最终模型。然而,纹理是通过材质应用并由着色器操纵的:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

纹理

纹理是应用于模型的 2D 图像,以增加模型的细节和视图质量。这个图像被映射到网格的表面上,以便每个表面渲染纹理的特定部分。

着色器

着色器用于操纵纹理的质量、颜色和其他属性,使其更加逼真。大多数情况下,不可能创建具有所有属性正确设置的纹理。3D 模型的可见性取决于光源、强度、颜色和材质类型。

材料

材料确定纹理属性和着色器属性。在将其应用于网格以创建模型之前,材料可以被称为着色器和纹理的容器。

碰撞检测

3D Android 游戏的碰撞检测可以分为两种类型:

  • 原始碰撞器

  • 网格碰撞器

原始碰撞器

这些碰撞器由立方体、球体、圆柱体、棱柱体等基本的 3D 元素组成。这种碰撞检测系统遵循一定的几何模式和规则。这就是为什么它相对于任意网格碰撞器来说比较简单的原因。

大多数情况下,开发人员会为许多模型分配原始碰撞器,以提高游戏的性能。这种方法显然不如实际的碰撞器精确。

网格碰撞器

网格碰撞器可以检测实际的任意碰撞检测。这种碰撞检测技术处理开销很大。有一些算法可以减少处理开销。四叉树、kd 树和 AABB 树是这种碰撞检测技术的几个例子。然而,它们并不能显著减少 CPU 开销。

最古老但最准确的方法是对每个表面进行三角形到三角形的碰撞检测。为了简化这种方法,每个网格块都被转换为盒子。生成特殊的 AABB 树或四叉树以减少顶点检查。

这可以通过合并两个盒子碰撞器来进一步减少到八叉树顶点映射。通过这种方式,开发人员可以减少碰撞检查以减少 CPU 开销。

射线投射

射线投射是一种几何系统,用于检测 3D 图形对象的表面。这个系统用于解决 3D 计算机图形的几何问题。在 3D 游戏中,所有 3D 对象都被投影到 2D 视图中。在 2D 电子显示器的情况下,没有射线投射是无法确定深度的:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

从原点发射的每条射线都可以检测不同对象上的形状、距离、碰撞检测、旋转和缩放等信息。

在 Android 游戏中,射线投射被广泛用于处理屏幕上的触摸输入。大多数游戏使用这种方法来操纵游戏中使用的 3D 对象的行为。

从开发性能的角度来看,射线投射是一个在大规模使用时成本相当高的系统。这需要一系列几何计算,导致处理开销。随着射线数量的增加,处理过程变得更加繁重。

在一个点上使用多个射线投射时,始终要控制好。

“世界”的概念

3D 游戏中的“世界”是实际世界的实时模拟,具有区域限制。世界是用 3D 模型创建的,这些模型指的是现实世界中的实际物体。游戏世界的范围是有限的。这个世界遵循特定的比例、位置和旋转,以及相应的摄像机。

摄像机的概念对于模拟这样一个世界是必不可少的。可以使用多个摄像机来渲染同一个世界的不同视角。

在游戏行业中,游戏世界是根据需求创建的。这意味着不同游戏的世界是不同的。但是一些参数保持不变。这些参数如下:

  • 有限元素

  • 光源

  • 相机

游戏世界的元素

世界由游戏设计中所需的元素组成。每个游戏可能需要不同的元素。然而,跨游戏的两个共同点是天空和地形。大多数元素通常放置在地形上,光源在天空中。然而,许多游戏在游戏的不同范围内提供不同的光源。

元素可以分为两类:可移动对象和静态对象。游戏的刚体与这些元素相关联。通常,静态对象不支持运动物理学。

对世界中的对象进行优化对性能至关重要。每个对象都有一定数量的顶点和三角形。我们已经讨论了 3D 对象顶点的处理开销。一般来说,世界优化基本上是对世界中每个元素的优化。

游戏世界中的光源

游戏世界必须有一个或多个光源。光源用于暴露世界中的元素。多个光源对用户体验有很大的视觉影响。

游戏开发过程总是需要至少一个优秀的光照艺术家。现代游戏使用光照图来增强视觉质量。游戏世界中的光与影是完全依赖于光照图的。

毫无疑问,光是游戏世界中的必要元素。然而,处理光和阴影的后果是大量的处理。所有顶点都需要根据特定的着色器处理光源。使用大量光源会导致性能低下。

光源可以是以下类型:

  • 区域光

  • 聚光灯

  • 点光源

  • 定向光

  • 环境光

  • 体积光

区域光

这种光源用于照亮矩形或圆形区域。本质上,它是定向光,以相等的强度照亮区域:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

聚光灯

聚光灯用于以圆锥形方向聚焦于特定对象:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

点光源

点光源照亮光源的所有方向。一个典型的例子是灯泡的照明:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

定向光

定向光是投射在 3D 世界中某个地方的一组平行光束。一个典型的例子是阳光:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

环境光

环境光是任意方向上的一组光束。通常,这种光源的强度较低。由于光束没有特定的方向,并且不会产生任何阴影:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

L1L2L3L4在这里是环境光源。

体积光

体积光是修改后的点光源类型。这种光源可以转换为在定义的几何形状内的一组光束。任何光束都是这种光源的完美例子:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

游戏世界中的相机

相机是游戏世界中最后但最重要的元素。相机负责渲染游戏屏幕。它还确定要添加到渲染管线中的元素。

游戏中使用的相机有两种类型。

透视相机

这种类型的相机通常用于渲染 3D 对象。可见的比例和深度完全取决于这种类型的相机。开发人员通过操纵视野和近/远范围来控制渲染管线:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

正交相机

这种类型的相机用于从 2D 透视渲染对象,而不考虑对象。正交相机在同一平面上渲染对象,而不考虑深度。开发人员操纵相机的有效宽度和高度来控制 2D 渲染管线。这种相机通常用于 2D 游戏和在 3D 游戏中渲染 2D 对象:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

除此之外,游戏相机也可以根据其性质和目的进行分类。以下是最常见的变化。

固定相机

固定相机在执行过程中不会旋转、平移或缩放。通常,2D 游戏使用这样的相机。固定相机在处理速度方面是最方便的相机。固定相机没有任何运行时操作。

旋转相机

这种相机在运行时具有旋转功能。这种相机类型在体育模拟或监控模拟游戏中非常有效。

移动相机

当平移在运行时可以改变时,相机可以被称为移动。这种类型的相机通常用于游戏的俯视图。这种相机的典型用途是像《帝国时代》、《英雄公司》、《部落冲突》等游戏。

第三人称相机

这种相机主要是游戏设计的一部分。这是一个移动相机,但这个相机跟随特定的对象或角色。角色应该是用户角色,因此所有的动作和移动都由这个相机跟踪,包括角色和对象。大多数情况下,这个相机可以根据玩家的动作进行旋转或推动。

第一人称相机

当玩家扮演主角时,这个相机用于实现玩家眼睛的典型视图。相机根据玩家的动作移动或平移。

Android 中的渲染管线

现在让我们来看看 Android 中的渲染管线类型。

2D 渲染管线

在 Android 的 2D 绘图系统中,所有资产首先绘制在画布上,然后画布被渲染在屏幕上。图形引擎根据给定位置在有限的画布上映射所有资产。

通常,开发人员单独使用小资产,导致每个资产执行映射指令。建议您尽可能使用精灵表来合并尽可能多的小资产。然后可以应用单个绘制调用来在画布上绘制每个对象。

现在,问题是如何创建精灵以及其他后果是什么。以前,Android 不能支持尺寸超过 1024 x 1024 像素的图像或精灵。自 Android 2.3 以来,开发人员可以使用 4096 x 4096 的精灵。然而,使用这样的精灵可能会导致所有小资产的永久内存占用。许多低配置的 Android 设备不支持在应用程序加载期间加载如此大的图像。最佳做法是开发人员限制自己在 2048 x 2048 像素。这将减少内存使用峰值,以及对画布的大量绘制调用。

3D 渲染管线

Android 使用 OpenGL 在屏幕上渲染资产。因此,Android 3D 的渲染管线基本上是 OpenGL 管线。

让我们来看看 OpenGL 渲染系统:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

现在,让我们详细看一下前述渲染流程图的每个步骤:

  1. 顶点着色器处理具有顶点数据的单个顶点。

  2. 控制着色器负责控制顶点数据和镶嵌的补丁。

  3. 多边形排列系统使用由顶点创建的每对相交线排列多边形。因此,它创建边缘而不重复顶点。

  4. 镶嵌是在不重叠或有任何间隙的情况下对多边形进行平铺的过程。

  5. 几何着色器负责优化原始形状。因此会生成三角形。

  6. 在构建多边形和形状之后,模型被裁剪以进行优化。

  7. 顶点后处理用于过滤掉不必要的数据。

  8. 然后对网格进行光栅化。

  9. 片段着色器用于处理光栅化生成的片段。

  10. 所有像素在分割后都经过映射,并使用处理后的数据进行处理。

  11. 网格被添加到帧缓冲区进行最终渲染。

优化 2D 资源

任何数字游戏都无法在没有 2D 艺术资源的情况下制作。游戏内必须以某种形式存在 2D 资源。因此,就游戏组件优化而言,每个 2D 资源也应该被优化。优化 2D 资源意味着这三个主要方面。

大小优化

每个资产帧应该只包含在游戏中使用的有效像素。不必要的像素会增加资产大小,并在运行时增加内存使用。

数据优化

并非所有图像都需要像素的完整数据信息。根据图像格式,每个像素可能存储大量数据。例如,全屏不透明图像永远不应该包含透明数据。同样,根据颜色集,图像必须以 8 位、16 位或 24 位格式进行格式化。

图像优化工具可用于执行此类优化。

流程优化

在优化期间压缩的数据量越大,解压缩和加载到内存所需的时间就越长。因此,图像优化直接影响处理速度。

从另一个角度来看,创建图像图集或精灵表是减少图像处理时间的另一种方法。

优化 3D 资源

3D 艺术资源有两个部分需要优化。 2D 纹理部分需要以相同的 2D 优化风格进行优化。开发人员需要考虑的唯一一件事是,在优化后,着色器对结构应该有相同的效果。

其余的 3D 资产优化完全取决于顶点数量和模型多边形。

限制多边形数量

很明显,使用大量多边形来创建网格可以创建更多细节。但是,我们都知道 Android 是一个移动操作系统,它总是有硬件限制。

开发人员应该计算网格中使用的多边形数量以及在单个绘制周期内屏幕上呈现的多边形总数。根据硬件配置,总会有限制。

因此,限制每个网格的多边形和顶点数量总是有利于实现特定的帧速率或性能。

模型优化

模型是用多个网格创建的。在最终模型中使用单独的网格总是会导致大量处理。这对游戏艺术家来说是一项重大工作。如果使用多个网格,可能会发生多重重叠。这会增加顶点处理。

绑定是最终确定模型的另一个重要部分。一个好的绑定者会为最少的处理定义骨骼。

常见的游戏开发错误

在每个开发阶段都不可能查看每个性能方面。在临时模式下使用资产和编写代码,并在最终游戏中使用它是一种非常常见的做法。

这会影响整体性能和未来的维护程序。以下是在游戏开发过程中常见的一些错误。

使用未优化的图像

艺术家创建艺术资产,开发人员直接将其集成到游戏中进行调试构建。然而,大部分时间,这些资产甚至没有针对发布候选版本进行优化。

这就是为什么可能会有大量高位图像,其中资产包含有限的信息。Alpha 信息可能存在于不透明图像中。

使用完整的实用第三方库

现代开发风格不要求每个开发模块都从头开始编写。大多数开发人员使用预定义的第三方库来进行常见的实用机制。

大多数情况下,这些包含大部分可能方法的包,而其中很少有实际在游戏中使用。开发人员大多数情况下在没有任何过滤的情况下使用这些包。在这种情况下,大量未使用的数据会在运行时占用内存。

通常,第三方库没有编辑功能。在这种情况下,开发人员应根据其特定需求非常谨慎地选择这些包。

使用未经管理的网络连接

在现代安卓游戏中,使用互联网连接非常普遍。许多游戏使用基于服务器的游戏玩法。在这种情况下,整个游戏在服务器上运行,并且服务器和客户端设备之间频繁传输数据。每个数据传输过程都需要时间,连接会显著消耗电池电量。

糟糕管理的网络状态经常会冻结应用程序。特别是对于实时多人游戏,处理大量数据。在这种情况下,应该创建并正确管理请求和响应队列。然而,开发人员经常跳过这一部分以节省开发时间。

未经管理的连接的另一个方面是服务器和客户端之间不必要的数据包传输。因此,每次传输数据时都涉及额外的解析过程。

使用次标准的编程

我们已经讨论了编程风格和标准。模块化编程方法可能会增加一些额外的过程,但长期管理编程需要模块化编程。否则,开发人员最终会重复代码,这会增加过程开销。

内存管理也需要良好的编程风格。在一些情况下,开发人员分配内存但经常忘记释放内存。这会导致大量内存泄漏。有时,应用程序由于内存不足而崩溃。

次标准的编程包括以下错误:

  • 多次声明相同的变量

  • 创建许多静态实例

  • 编写非模块化的编码

  • 不正确的单例类创建

  • 运行时加载对象

采取捷径

这是不良开发风格中最有趣的事实。在开发过程中采取捷径在游戏开发人员中非常普遍。

制作游戏主要是逻辑开发。解决逻辑问题可能有多种方法。开发人员经常选择最方便的方式来解决这些问题。例如,开发人员大多数情况下使用冒泡排序方法来满足大部分排序需求,尽管知道这是最低效的排序过程。

在游戏中多次使用这样的捷径可能会导致可见的进程延迟,直接影响帧率。

2D/3D 性能比较

2D 和 3D 的安卓游戏开发是不同的。事实上,3D 游戏处理比 2D 游戏更加繁重。然而,游戏规模始终是决定因素。

不同的外观和感觉

3D 的外观和感觉与 2D 完全不同。在 3D 游戏中使用粒子系统来提供视觉效果非常普遍。在 2D 游戏中,使用精灵动画和其他转换来展示这些效果。

2D 和 3D 外观的另一个区别是动态光和阴影。动态光始终是更高视觉质量的一个因素。如今,大多数 3D 游戏使用动态光照,这对游戏性能有显著影响。在 2D 游戏中,光管理是通过资源完成的。因此,在 2D 游戏中没有额外的光和阴影处理。

在 2D 游戏中,游戏屏幕是在画布上渲染的。只有一个固定的视角。因此,摄像头的概念局限于固定摄像头。然而,在 3D 游戏中,情况就不同了。可以实现多种类型的摄像头。可以同时使用多个摄像头以获得更好的游戏体验。通过多个摄像头渲染对象会导致更多的处理开销。因此,它会降低游戏的帧率。

在使用 2D 物理和 3D 物理之间存在显著的性能差异。3D 物理引擎比 2D 物理引擎要重得多。

3D 处理比 2D 处理要重得多

在游戏行业中,接受 3D 游戏的 FPS 比 2D 游戏要少是一种常见做法。在 Android 中,2D 游戏的标准接受的 FPS 约为 60 FPS,而 3D 游戏即使以低至 40 FPS 的速度运行也是可以接受的。

这背后的逻辑原因是,从处理的角度来看,3D 游戏比 2D 游戏要重得多。主要原因如下:

  • 顶点处理:在 3D 游戏中,每个顶点在渲染过程中在 OpenGL 层上进行处理。因此,增加顶点数量会导致更重的处理。

  • 网格渲染:网格由多个顶点和许多多边形组成。处理网格会增加渲染开销。

  • 3D 碰撞系统:3D 动态碰撞检测系统要求对每个碰撞体的顶点进行计算以进行碰撞。这个计算通常由 GPU 完成。

  • 3D 物理实现:3D 变换计算完全取决于矩阵操作,这总是很重的。

  • 多摄像头使用:使用多个摄像头并动态设置渲染管线需要更多的内存和时钟周期。

设备配置

Android 平台支持各种设备配置选项。在前几章中,我们已经看到了这样的变化。在不同配置上运行相同的游戏不会产生相同的结果。

性能取决于以下因素。

处理器

在 Android 设备中使用了许多处理器,涉及核心数量和每个核心速度。速度决定了在单个周期内可以执行的指令数量。曾经有一段时间,Android 使用的是速度低于 500 MHz 的单核 CPU。现在我们有了每个核心超过 2 GHz 速度的多核 CPU。

RAM

可用 RAM 是决定性能的另一个因素。重型游戏在运行时需要更多的 RAM。如果 RAM 有限,频繁的加载/卸载过程会影响性能。

GPU

GPU 决定了渲染速度。它充当了图形对象的处理单元。更强大的处理器可以处理更多的渲染指令,从而提高性能。

显示质量

显示质量实际上与性能成反比。更好的显示质量必须由更好的 GPU、CPU 和 RAM 支持,因为更好的显示总是由更大的分辨率、更好的 dpi 和更多的颜色支持组成。

我们可以看到各种不同显示质量的设备。Android 本身已经根据这个特性划分了资源:

  • LDPI:Android 的最低 dpi 显示(~120 dpi)

  • MDPI:Android 的中等 dpi 显示(~160 dpi)

  • HDPI:Android 的高 dpi 显示(~240 dpi)

  • XHDPI:Android 的额外高 dpi 显示(~320 dpi)

  • XXHDPI:Android 的额外额外高 dpi 显示(~480 dpi)

  • XXXHDPI:Android 的额外额外额外高 dpi 显示(~640 dpi)

可以很容易地预测,随着硬件技术的进步,列表将在不久的将来包含更多选项。

电池容量

电池容量是应用性能中的一个奇怪因素。更强大的 CPU、GPU 和 RAM 需要更多的电力。如果电池无法提供电力,那么处理单元就无法以最高效率运行。

总结这些因素,我们可以很容易地用性能做出一些关系方程:

  • CPU 与性能成正比

  • GPU 与性能成正比

  • RAM 与性能成正比

  • 显示质量与性能成反比

  • 电池容量与性能成正比

总结

随着更高质量和性能的 3D 游戏范围不断扩大。然而,这需要硬件支持运行 Android 平台。旧设备尚未过时。

当同一应用在不同设备上运行时,这就成为一个严重的问题。这对开发人员来说是一个挑战,要在不同设备上运行同一应用。

在渲染、处理和资产方面,2D 和 3D 游戏之间存在许多技术差异。开发人员应始终使用优化的方法来创建资产和编写代码。另一种提高性能的方法是为 2D 和 3D 游戏的不同硬件系统移植游戏。

自上个十年以来,硬件平台已经有了革命性的升级。相应地,游戏的性质也发生了变化。然而,2D 游戏的范围仍然存在着大量的可能性。

有许多用于开发 2D 和 3D 游戏的框架和引擎。对多个操作系统的支持也增加了对 2D 和 3D 游戏的价值。

提高性能更多是一个逻辑任务,而不是技术任务。有一些可用的工具来完成这项工作,但选择权在开发人员手中。因此,选择合适的工具是必要的,制作 2D 和 3D 游戏应该有不同的方法。

我们已经讨论了 2D 和 3D 开发中的渲染过程。我们将在本书的后面进一步利用 Android 中的着色器来增强渲染,并尝试探索优化 Android 游戏的各种技术。

第七章:使用着色器

每个游戏的成功在很大程度上取决于其外观和感觉。这直接意味着游戏必须具有引人注目的图形显示。由于空间和堆限制,通常无法提供最高质量的图形资产。因此,必须有一种方法在运行时创建或改进图形资产以供显示。这种必要性催生了着色器的概念。

着色器可以操作任何可视元素,并在渲染之前调整可绘制元素的每个像素。大多数情况下,着色器针对特定的图形处理器进行了优化。然而,如今,着色器可以编写以支持多个平台上的多个处理器。

Android 在 Android 框架本身中提供了使用着色器的选项。此外,还可以使用 OpenGL 着色器,并借助 Android NDK 进行自定义。有许多场合,借助着色器可以在没有优秀原始艺术资产的情况下提供精美的图形质量。

我们将从 Android 游戏开发的角度讨论着色器,涵盖以下主题:

  • 着色器介绍

  • 着色器的工作原理

  • 着色器的类型

  • Android 库着色器

  • 编写自定义着色器

  • 通过 OpenGL 的着色器

  • 游戏中的着色器使用

  • 着色器和游戏性能

着色器介绍

许多开发人员在 Android 上开发游戏,但对着色器了解不多。在大多数情况下,开发人员不需要使用着色器,或者在游戏开发框架或引擎中有一些预定义的着色器。

1988 年,动画工作室 Pixar 引入了现代着色器概念。然而,当时的 GPU 无法处理着色器。OpenGL 和 Direct3D 是支持着色器的第一批图形库。GPU 开始通过 2D 像素着色支持着色器。很快,它得到了增强,以支持顶点着色器。如今,OpenGL 3.2 和 Direct3D 10 库也支持几何着色器。

现在让我们深入了解着色器的定义、必要性和在 Android 游戏中的范围。

什么是着色器?

简而言之,着色器是一组指令,用于操纵输入图形资产的视觉显示。

让我们稍微详细解释一下定义。所有指令基本上都是通过编程完成的。这就是为什么着色器的概念只存在于计算机图形中的原因。着色器能够根据指令和输入资产执行计算,以产生更有效的输出可显示资产。

典型的着色器可以处理顶点或像素。像素着色器可以计算资产的颜色、深度和 alpha 属性。顶点着色器可以计算顶点的位置、颜色、坐标、深度、光照等。因此,着色器可以根据操作基本类型主要分为两类:

  • 2D 着色器

  • 3D 着色器

着色器的必要性

在游戏开发的正常实践中,Android 开发人员不太关心着色器。但是,着色器的必要性是不可避免的。最初,对于小规模游戏,艺术资产是未经改进的。对资产的任何修改都是通过更新艺术资产本身的旧流程来管理的。

着色器可以最小化这种额外耗时的工作。同一资产可以被操纵以在屏幕上创建不同的对象。例如,您可以在对象失焦时使其模糊,改变游戏过程中精灵的颜色以表示不同的团队或玩家,创建艺术资产的蒙版等等。

着色器具有以下好处:

  • 当不同的着色器应用于相同的艺术资产时,根据运行时的要求,会产生不同的资产。因此,着色器可以节省额外的艺术创作时间。

  • 游戏中一次性集成可绘制对象可以通过不同的着色器产生不同的视觉体验。

  • 由于艺术资产被最小化,使用着色器可以减少游戏构建大小。

  • 相同的资产集会有更多的视觉差异。

  • 通过着色器可以通过简单的艺术重复操纵视觉内容来创建动画。

  • 着色器在运行时创建视觉效果很有用。

然而,使用着色器可能会导致一些负面后果:

  • 使用着色器会增加处理时间,因为在运行时操纵视觉资产

  • 未经优化地使用着色器可能导致更多的堆内存消耗,因为其中将存储各种中间实例

  • 有时,着色器在处理时负责对象的扭曲

  • 使用着色器使艺术资产容易受到质量损失

只有前两种是使用着色器的实际直接后果。其余的问题只有在开发人员使用编写不好的着色器或有故障的着色器时才会发生。因此,选择特定任务的完美着色器是非常必要的。

有时,着色器处理需要很长时间,导致 FPS 输出不佳。一些旧的 GPU 不支持所有类型的着色器。因此,开发人员应检查和确认着色器要执行的硬件平台。

着色器的范围

着色器可以用于与计算机图形相关的各种领域,如图像处理、摄影、数字动画、视频/电脑/数字游戏等。

游戏行业是使用着色器的最大社区之一。安卓平台也不例外。安卓游戏开发人员在 3D 和 2D 游戏中大规模使用着色器。

坦率地说,2D 游戏对着色器的范围不大。只有像素着色器可以操纵像素的颜色、不透明度、饱和度和色调。当相同的原始资产用于不同的可见性时,这是很有用的。

例如,一个 2D 板球游戏有许多队伍,他们穿着不同的服装以区分它们。开发人员将所有精灵动画资产设计成一个设计,并应用着色器以不同方式操纵颜色以区分不同的队伍。因此,输出的精灵具有不同的可见性,并且玩家可以轻松识别它们。

着色器的工作原理

我们已经讨论过着色器处理顶点或像素。因此,基本的工作原理是在运行时更改或操纵数据:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

着色器过程是一组特定的指令,用于处理顶点或片段。可以为各种类型的处理编写不同的着色器程序。

顶点着色器用于改变模型的形状;它还可以改变表面形成系统。

像素/片段着色器可以改变像素颜色值以及不透明度。像素数据可以通过着色器程序合并、修改或替换,以形成新的数字图像。

着色器的类型

游戏行业中使用了许多着色器。它们根据其行为和特性进行分类。以下是一些着色器:

  • 像素着色器

  • 顶点着色器

  • 几何着色器

  • 镶嵌着色器

让我们详细看看这些类型。

像素着色器

像素着色器是在纹理或数字图像上工作的 2D 着色器。像素着色器处理单个像素的颜色和其他属性。每个单个像素称为片段。这就是为什么像素着色器经常被称为片段着色器的原因。

顶点着色器

顶点着色器主要作用于网格或模型的顶点。每个模型的网格由多个顶点组成。顶点着色器只能应用于 3D 模型。因此,顶点着色器是一种 3D 着色器。

几何着色器

几何着色器用于创建新的基本图形元素。在应用顶点着色器以执行渲染管线后,几何着色器用于创建点、线和三角形以形成表面。

镶嵌着色器

这是一个典型的 3D 着色器,用于简化和改进镶嵌期间的 3D 网格。它分为两个着色器:

  • Hull 着色器或镶嵌控制着色器

  • 域着色器或镶嵌进化着色器

这两个着色器一起使用以减少网格带宽。

镶嵌着色器有能力以显著减少可绘制顶点数的方式改进 3D 模型。因此,渲染变得更快。

Android 库着色器

Android 在android.graphics包中的框架中提供了着色器选项。一些知名和广泛使用的着色器也在 Android 库中。其中一些如下:

  • BitmapShader:这可以用于以纹理格式绘制位图。它还支持位图的平铺或镜像。它非常适用于创建具有平铺的地形。

  • ComposeShader:这用于合并两个着色器。因此,它非常适用于掩模或合并两种不同着色器的颜色。

  • LinearGradient:这用于沿着给定的线段创建渐变,并带有定义的颜色集。

  • RadialGradient:这用于创建沿着给定圆弧段的渐变,并带有定义的颜色集。提供了径向原点和半径来创建渐变。

  • SweepGradient:这用于在给定半径周围创建扫描渐变颜色。

这里是一个例子:

@Override
protected void onDraw ( Canvas c)
{
  float px = 100.0f;
  float py = 100.0f;
  float radius = 50.0f;

  int startColor = Color.GREEN;
  int targetColor = Color.RED;

  Paint shaderPaint = new Paint();
  shaderPaint.setStyle(Paint.Style.FILL);

  //LinearGradient Example to a circular region
  LinearGradient lgs = new LinearGradient( px, py, px + radius, py + radius, 
    startColor, targetColor, Shader.TileMode.MIRROR);
  shaderPaint.setShader(lgs);
  c.drawCircle( px, py, radius, shaderPaint);

  //RadialGradient Example to a circular region
  px = 200.0f;
  py = 200.0f;
  RadialGradient rgs = new LinearGradient( px, py, radius, 
    startColor, targetColor, Shader.TileMode.MIRROR);
  shaderPaint.setShader(rgs);
  c.drawCircle( px, py, radius, shaderPaint);

  //SweepGradient Example to a circular region
  px = 300.0f;
  py = 300.0f;
  shaderPaint.setShader(new SweepGradient(px, py, startColor, targetColor));
  c.drawCircle( px, py, radius, shaderPaint);
}

这是它的样子:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这些选项非常适合使用不同的样式创建不同对象的相同原始对象。

编写自定义着色器

开发人员可以根据自己的需求编写自定义着色器。Android 提供了android.graphics.Shader类。使用提供的原始着色器很容易创建自己的着色器类。

自定义着色器可能不仅包括一个着色器。它可以是各种着色器的组合。例如,考虑使用运动触摸事件在圆形视口中遮罩图像:

private float touchX;
private float touchY;
private boolean shouldMask = false;

private final float viewRadius;
private Paint customPaint;

@Override
public boolean onTouchEvent(MotionEvent motionEvent) 
{
  int pointerAction = motionEvent.getAction();
  if ( pointerAction == MotionEvent.ACTION_DOWN || 
  pointerAction == MotionEvent.ACTION_MOVE )
    shouldMask = true;
  else
    shouldMask = false;

  touchX = motionEvent.getX();
  touchY = motionEvent.getY();
  invalidate();
  return true;
}

@Override
protected void onDraw(Canvas canvas) 
{
  if (customPaint == null) 
  {
    Bitmap source = Bitmap.createBitmap( getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
    Canvas baseCanvas = new Canvas(source);
    super.onDraw(baseCanvas);

    Shader customShader = new BitmapShader(source, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);

    customPaint = new Paint();
    customPaint.setShader(customShader);
  }

  canvas.drawColor(Color.RED);
  if (shouldMask) 
  {
    canvas.drawCircle( touchX, touchY - viewRadius, viewRadius, customPaint);
  }
}

这个例子是图片游戏中最常用的着色器样式之一。您也可以实现这样的着色器来创建隐藏物体游戏。

另一个用例是突出显示屏幕上的特定对象。同样的可视圆可以用来只显示突出的对象。在这种情况下,颜色可以是半透明的,以显示暗淡的背景。

通过 OpenGL 的着色器

在 Android 中,OpenGL 支持为 Android 3D 游戏实现着色器。OpenGL ES 2.0 是 Android 上支持着色器的平台。在手动创建着色器时,它有两个功能段:

  • 着色器

  • 程序

着色器被转换成中间代码以支持程序在 GPU 上运行。在编译阶段,着色器被转换。这就是为什么着色器在程序执行之前需要重新编译的原因。

在我们的例子中,我们将使用android.opengl包的GLSurfaceView

对于 3D 游戏,Android 开发人员可以使用这个包在 Android SDK 上玩转着色器。这个包提供了使用 Java 创建和使用 OpenGL 着色器的 API。

我们将使用GLSurfaceView而不是普通的 AndroidViewSurfaceView。实现将如下所示:

import android.opengl.GLSurfaceView;
import android.content.Context;

public class MyGLExampleView extends GLSurfaceView 
{
  private final GLRenderer mRenderer;

  public MyGLExampleView (Context context) 
  {
    super(context);

// Set OpenGL version 2.0 as we will be working with that particular library
    this.setEGLContextClientVersion(2);

// Set the Renderer for drawing on the GLSurfaceView
    MyOpenGLRendererExample = new MyOpenGLRendererExample (context);
    setRenderer(mRenderer);

// Render the view only when there is a change in the //drawing data
    setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY);
  }

  @Override
  public void onPause() 
  {
    super.onPause();
  }

  @Override
  public void onResume() 
  {
    super.onResume();
  }
}

我们需要为视图创建一个渲染器,通过 OpenGL 绘制对象:

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.FloatBuffer;
import java.nio.ShortBuffer;

import android.content.Context;
import android.opengl.GLES20;
import android.opengl.GLSurfaceView.Renderer;
import android.opengl.Matrix;
import android.util.Log;

import javax.microedition.khronos.egl.EGLConfig;
import javax.microedition.khronos.opengles.GL10;

public class MyOpenGLRendererExample implements Renderer 
{

  // Declare matrices
  private float[] matatrixProjection = new float[32];
  private float[] matrixView = new float[32];
  private float[] matatrixProjectionOnView = new float[32];

  // Declare Co-ordinate attributes
  private float vertexList[];
  private short indicxList[];
  private FloatBuffer vertexBuffer;
  private ShortBuffer drawBuffer;

  private final String vertexShader =
          "uniform    mat4        uMVPMatrix;" +
          "attribute  vec4        vPosition;" +
          "void main() {" +
          "  gl_Position = uMVPMatrix * vPosition;" +
          "}";

  private final String pixelShader =
          "precision mediump float;" +
          "void main() {" +
          "  gl_FragColor = vec4(0.5,0,0,1);" +
          "}";

  // Declare Screen Width and Height HD display
  float ScreenWidth = 1280.0f;
  float ScreenHeight = 800.0f;

  private int programIndex = 1;

  public MyOpenGLRendererExample (Context context)
  {

  }

  @Override
  public void onDrawFrame(GL10 param) 
  {
    renderView(matatrixProjectionOnView);
  }

  @Override
  public void onSurfaceChanged(GL10 objGL, int width, 
int height) 
  {
    ScreenWidth = (float)width;
    ScreenHeight = (float)height;

    GLES20.glViewport(0, 0, (int)ScreenWidth, 
(int)ScreenHeight);

    //reset matrices
    for( int i = 0; i < 32 ; ++ i )
    {
      matatrixProjection[i] = 0.0f;
      matrixView[i] = 0.0f;
      matatrixProjectionOnView[i] = 0.0f;
    }

    Matrix.orthoM(matatrixProjection, 0, 0f, ScreenWidth, 
0.0f, ScreenHeight, 0, 50);

    Matrix.setLookAtM(matrixView, 0, 0f, 0f, 1f, 0f, 0f, 
0f, 0f, 1.0f, 0.0f);

    Matrix.multiplyMM(matatrixProjectionOnView, 0, 
matatrixProjection, 0, matrixView, 0);
  }

  @Override
  public void onSurfaceCreated(GL10 gl, EGLConfig config) 
  {
    //create any object 
    //Eg. Triangle:: simplest possible closed region

    createTriangle();

    // Set the color to black
    GLES20.glClearColor(0.0f, 0.0f, 0.0f, 1);

    // Create the shaders
    int vertexShaderTmp = 
loadShader(GLES20.GL_VERTEX_SHADER, vertexShader);

    int pixelShaderTmp = 
loadShader(GLES20.GL_FRAGMENT_SHADER, pixelShader);

    int programIndexTmp = GLES20.glCreateProgram();

    GLES20.glAttachShader(programIndexTmp, 
vertexShaderTmp);

    GLES20.glAttachShader(programIndexTmp, 
pixelShaderTmp);

    GLES20.glLinkProgram(programIndexTmp);

    // Set shader program
    GLES20.glUseProgram(programIndexTmp);
  }

  void renderView(float[] matrixParam) 
  {
    int positionHandler = 
GLES20.glGetAttribLocation(programIndex, "vPosition");

    GLES20.glEnableVertexAttribArray(positionHandler);
    GLES20.glVertexAttribPointer(positionHandler, 3, 
GLES20.GL_FLOAT, false, 0, vertexBuffer);

    int mtrxhandle = 
GLES20.glGetUniformLocation(programIndex, "uMVPMatrix");

    GLES20.glUniformMatrix4fv(mtrxhandle, 1,
 false, matrixParam, 0);

    GLES20.glDrawElements(GLES20.GL_TRIANGLES, 
indicxList.length, GLES20.GL_UNSIGNED_SHORT, drawBuffer);

    GLES20.glDisableVertexAttribArray(positionHandler);  
  }

  void createTriangle()
  {
    // We have to create the vertexList of our triangle.
    vertexList = new float[]
    {
      20.0f, 200f, 0.0f,
      20.0f, 300f, 0.0f,
      200f, 150f, 0.0f,
    };

    //setting up the vertex list in order
    indicxList = new short[] {0, 1, 2};

    ByteBuffer bytebufVertex = 
ByteBuffer.allocateDirect(vertexList.length * 4);

    bytebufVertex.order(ByteOrder.nativeOrder());
    vertexBuffer = bytebufVertex.asFloatBuffer();
    vertexBuffer.put(vertexList);
    vertexBuffer.position(0);

    ByteBuffer bytebufindex = 
ByteBuffer.allocateDirect(indicxList.length * 2);

    bytebufindex.order(ByteOrder.nativeOrder());
    drawBuffer = bytebufindex.asShortBuffer();
    drawBuffer.put(indicxList);
    drawBuffer.position(0);

    int vertexShaderTmp = 
loadShader(GLES20.GL_VERTEX_SHADER, vertexShader);

    int pixelShaderTmp = 
loadShader(GLES20.GL_FRAGMENT_SHADER, pixelShader);

    int program = GLES20.glCreateProgram();
    if (program != 0) 
    {
      GLES20.glAttachShader(program, vertexShaderTmp);
      GLES20.glAttachShader(program, pixelShaderTmp);
      GLES20.glLinkProgram(program);

      int[] linkStatus = new int[1];

      GLES20.glGetProgramiv(program, 
GLES20.GL_LINK_STATUS, linkStatus, 0);

      if (linkStatus[0] != GLES20.GL_TRUE) 
      {
        Log.e("TAG_EXAMPLE_OPENGL", "Linking Failed !! 
Error:: " + GLES20.glGetProgramInfoLog(program));

        GLES20.glDeleteProgram(program);
        program = 0;
      }
    }
  }

// method to create shader
   int loadShader(int type, String shaderCode)
   {
        int shader = GLES20.glCreateShader(type);

        GLES20.glShaderSource(shader, shaderCode);
        GLES20.glCompileShader(shader);

        return shader;
    }
}

顶点着色器代码(String vs_SolidColor)有两个需要的参数。uMVPMatrix参数是mat4类型,它保存了可以用来平移位置的变换矩阵。uMVPMatrix参数是一个统一矩阵。vPosition参数是vec4类型,它保存了顶点的位置。

这个系统可以应用于三角形表面。

在游戏中使用着色器

着色器在游戏和动画中被广泛使用,特别是在创建动态照明、改变色调和进行动态视觉改进时。有时,世界环境是用着色器创建的。

2D 游戏空间中的着色器

只有像素着色器可以在 2D 游戏中使用。数字图像的每个像素被视为一个片段。这就是为什么像素着色器也被称为片段着色器的原因。像素着色器只能执行颜色更改、平铺和遮罩。

BitmapShaderComposeShaderLinearGradientRadialGradientSweepGradient是 Android 2D 着色器的变体。

2D 游戏世界是由图像创建的。开发人员通常选择创建不同的资源,以赋予相同对象不同的外观和感觉。在这个过程中,开发人员最终会创建一个几乎相同的使用集,但 APK 更大的 APK。

精灵也是着色器可以发挥重要作用的领域。当使用相同的精灵创建不同的对象时,某些片段的颜色需要动态改变。像素着色器在这里非常有用。

2D 空间中的着色器用于改变颜色,模糊段,改变亮度,改变不透明度,着色图像等。

3D 游戏空间中的着色器

在 3D 游戏中,着色器最常见的用途是动态阴影。在现代游戏开发中,阴影是改善游戏体验的不可避免的元素。应用纹理后,3D 模型看起来很真实。

在 Android 中,通过 OpenGL 应用 3D 着色器。我们已经讨论了一个例子:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

只有顶点信息的原始模型

这是一个没有任何光照或着色器的简单模型。让我们应用一些着色器,使其具有坚实的 3D 外观:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

应用简单的平面着色器

现在,开发人员可以应用任何纹理或颜色来赋予其不同的感觉。在这一部分,开发人员可以选择通过颜色或纹理来限制这一点。通常在这种情况下使用纹理,以使模型在视觉上更真实。然而,这比仅仅进行颜色操作要昂贵得多。

在这里我们将看到颜色和光照的变化,以获得完全不同的感觉。对于游戏的不同场景需求,有不同的处理程序。

然而,这个例子只是着色器如何操纵 3D 模型以获得不同外观和感觉的视觉表示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

着色器和游戏性能

着色器通常是处理密集型的。片段着色器处理纹理的每个片段并操纵其数据。大纹理可能会导致游戏循环中出现明显的延迟。

我们可以从不同的角度看待着色器,以创造出性能的概念。大纹理会降低性能,而许多小纹理也会影响性能。它们之间必须保持平衡,以便在实时使用着色器时具有可行性。

创建阴影是着色器的广泛用途之一。然而,阴影处理的质量与性能成反比。在高质量游戏中,我们可以体验实时阴影。着色器映射对象的顶点并根据光线方向进行处理。然后将其投影到X-Z平面上以创建阴影。阴影与平面上的对象和其他阴影合并。

着色器可用于改善世界的可见性,使用不同的光线、材质和颜色。

在游戏中使用着色器的一些优点:

  • 渲染资源的完全灵活性

  • 减少资源包,增加可重用性

  • 动态视觉效果

  • 动态光照和阴影

  • 实时精灵操作

使用着色器有一些缺点:

  • 帧率相对较低

  • 性能下降

  • 需要支持的硬件平台和图形驱动程序

尽管有一些缺点,着色器已经被证明足够成为游戏开发的固有部分。任何性能下降都可以通过升级硬件和图形驱动程序来处理。

如今,着色器正在针对资源有限的嵌入式设备进行优化。这甚至为在几乎每个平台上增加着色器的使用提供了机会,而不会显著影响性能。

总结

自 Android API 级别 15 起,框架支持 OpenGL ES 2.0。这为图形程序员在 Android 游戏中实现着色器提供了巨大的灵活性。

几乎每种硬件配置都支持着色器在 GPU 上运行。然而,使用着色器的规模决定了性能。在现代,这实际上并不是一个问题。

着色器在游戏中被广泛使用。在图形编程的各个方面,着色器已经证明了它们的重要性。所有著名和成功的游戏开发者都承认了着色器的重要性。图形艺术家不需要担心游戏中的所有视觉内容,这显著减少了开发时间。

因此,着色器在游戏中被广泛使用。新的着色器现在带有额外的功能。着色器的升级周期已经减少。然而,硬件也在升级,以支持图形更新的新技术。

看到一个简单的立方体变成具有相同方向的任何东西,感觉就像魔术。这种魔术将在未来更大规模地持续发生。

开发一款游戏并不足够。着色器在减少内存使用方面有很大帮助,但它们会增加处理开销。我们将在下一章尝试探索各种存储和处理的优化技术。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值