Delphi Sprite Engine – Part 3 – The Sprite.

pigeon

[If you’re new to this series, you may wish to skip ahead to part-7, where I’ve done a partial ‘start-over’]

Welcome to part 3 of this mini series on writing a Sprite Engine in Delphi. In this part we’ll finally create an animated sprite and render it to our viewport!
This part is going to be a little lengthy, make sure you have a coffee (or other favorite beverage) to hand.

Sprite Sheets.

In part 1 of this series of posts, we looked at projecting a 2D sprite onto a flat surface in 3D space. In doing so we found something interesting in the way we texture the object, that-is, because we’re able to specify texture coordinates, we don’t have to use the entire image to texture our sprite. In fact, it’s quite common practice in many graphics engines to load multiple textures into a single image, and then using texture coordinates, to select the region of the image to display. This is a common practice for many historical and technical reasons which I’ll not go into here, but, we’ll stick with this practice for our sprite engine.

My brother very kindly generated several frames of animation for me, which I pieced into the following image…

pidgy

The image contains several frames of an animation in which a pigeon (small dove) flaps it’s wings. We’re going to use this image to generate the animation in our sprite engine. I’ll include the image files, and all of the source code files in a download at the foot of this post.

In order to display any one cell of this animation we’ll need two vectors. The first vector will point at the top-left corner of the cell, and the second vector at the bottom-right corner. As we’re using the texture coordinates system, which scales to unity, we’ll need to scale the vectors also. Translating pixel locations to texture locations can be done with the following formula..

Tex = (1 / S) * P

Where S is the size of the image axis in pixels, and P is the pixel location that we want.
Lets create a class to represent an animation frame, and give it the ability to calculate our texture coordinates for us…

type
  TAnimationFrame = class
  private
    fTexture: TRectf;
  public
    constructor Create( TextureWidth, TextureHeight: int32; spriteX, spriteY, spriteWidth, SpriteHeight: int32 ); reintroduce;
  public
    property TexCoords: TRectf read fTexture;
  end;

Has only a constructor…

constructor TAnimationFrame.Create(TextureWidth, TextureHeight: int32; spriteX, spriteY, spriteWidth, SpriteHeight: int32 );
begin
  inherited Create;
  // Set the texture coords
  fTexture.Left := (1/TextureWidth) * SpriteX;
  fTexture.Top := (1/TextureHeight) * SpriteY;
  fTexture.Right := (1/TextureWidth) * (SpriteX+SpriteWidth);
  fTexture.Bottom := (1/TextureHeight) * (SpriteY+SpriteHeight);
end;

As we create this class, we pass in the dimensions of the image which contains the animation, and the pixel coordinates of the animation frame. We may then read the TexCoords property to get the texture coordinates for the animation frame.
If we have a list of TAnimationFrame, this will give us a complete animation. Lets create a class to represent an animation.

{# Represents an animation with a list of TAnimationFrame }
  TAnimation = class
  private
    fName: string;
    fFrames: TList;
    function GetCount: int32;
    function GetFrame(idx: int32): TAnimationFrame;
  public
    constructor Create( aName: string ); reintroduce;
    destructor Destroy; override;

    function AddFrame( aFrame: TAnimationFrame ): int32;
    procedure Clear;

  public
    property Name: string read fName;
    property Count: int32 read GetCount;
    property Frames[ idx: int32 ]: TAnimationFrame read GetFrame;
  end;

Implementation….

constructor TAnimation.Create( aName: string );
begin
  inherited Create;
  fName := aName;
  fFrames := TList.Create;
end;

destructor TAnimation.Destroy;
begin
  Clear;
  fFrames.Free;
  inherited Destroy;
end;

function TAnimation.AddFrame(aFrame: TAnimationFrame): int32;
begin
  Result := fFrames.Add(aFrame);
end;

procedure TAnimation.Clear;
var
  idx: int32;
begin
  for idx := pred(Count) downto 0 do begin
    TAnimationFrame(fFrames[idx]).Free;
  end;
  fFrames.Clear;
end;

function TAnimation.GetCount: int32;
begin
  Result := fFrames.Count;
end;

function TAnimation.GetFrame(idx: int32): TAnimationFrame;
begin
  Result := TAnimationFrame( fFrames[idx] );
end;

This class represents an animation by storing a list of TAnimationFrame classes, each of which store the texture coordinates of a frame of animation. Notice that this class has a name property. The name property will be used as a unique identifier for the animation. Our sprite class will look-up an animation by name, which gives us the option to give them useful names.

Now we need to bind the animation data with the image data. Lets create one more class, the all important TSpriteSheet…

type
  TSpriteSheet = class( TComponent )
  private
    fImage: TBitmap;
    fAnimations: TList;
    function GetAnimation(idx: int32): TAnimation;
    function GetAnimationByName(name: string): TAnimation;
    function GetCount: int32;
  public
    constructor Create( aOwner: TComponent ); override;
    destructor Destroy; override;

    procedure Clear;
    function AddAnimation( anAnimation: TAnimation ): int32;

  public
    property Image: TBitmap read fImage;
    // count of animations..
    property Count: int32 read GetCount;
    property Animation[ idx: int32 ]: TAnimation read GetAnimation;
    property AnimByName[ name: string ]: TAnimation read GetAnimationByName;

  end;

and implementation…

function TSpriteSheet.AddAnimation(anAnimation: TAnimation): int32;
begin
  if not assigned(GetAnimationByName(anAnimation.Name)) then begin
    Result := fAnimations.Add(anAnimation);
  end else begin
    raise Exception.Create('TSpriteSheet.AddAnimation: Animation name must be unique.');
  end;
end;

procedure TSpriteSheet.Clear;
var
  idx: int32;
begin
  for idx := pred(Count) downto 0 do begin
    TAnimation(fAnimations[idx]).Free;
  end;
  fAnimations.Clear;
end;

constructor TSpriteSheet.Create( aOwner: TComponent );
begin
  inherited Create( aOwner );
  fAnimations := TList.Create;
  fImage := TBitmap.Create;
end;

destructor TSpriteSheet.Destroy;
begin
  Clear;
  fAnimations.Free;
  fImage.Free;
  inherited;
end;

function TSpriteSheet.GetAnimation(idx: int32): TAnimation;
begin
  Result := TAnimation(fAnimations[idx]);
end;

function TSpriteSheet.GetAnimationByName(name: string): TAnimation;
var
  utName: string;
  idx: int32;
  Ref: TAnimation;
begin
  Result := nil;
  utName := Uppercase(Trim(name));
  if utName='' then exit;
  for idx := 0 to pred(Count) do begin
    Ref := TAnimation(fAnimations[idx]);
    if Uppercase(Trim(Ref.Name))=utName then begin
      Result := Ref;
      Exit;
    end;
  end;
end;

function TSpriteSheet.GetCount: int32;
begin
  Result := fAnimations.Count;
end;

I went right ahead and added all three of these classes to the same code unit, named unitSpriteSheet, and added it to the SpriteEngine package, also registering the TSpriteSheet component on the Tool Palette.

As it stands right now, we have to create sprite sheets manually. We’d create an instance of TSpriteSheet, load our image into it’s ‘Image’ property, and for each animation add an instance of TAnimation, and for each frame of animation an instance of TAnimationFrame. This is fine for now, but later we will add methods to save a sprite sheet to a stream, and to load a sprite sheet from a stream.

Finally, lets add the spite class and build up a test application…

unit unitSprite;

interface
uses
  classes, // for TComponent
  FMX.Types3D, // for TVertexBuffer
  FMX.Materials,
  unitSpriteSheet, // for TSpriteSheet
  unitViewportContext,
  unitSpriteScene;

type
  TSprite = class(TSceneComponent)
  private
    fVertexBuffer: TVertexBuffer;
    fIndexBuffer: TIndexBuffer;
    fSpriteSheet: TSpriteSheet;
  private
    fMaterial: TTextureMaterial;
    fFrame: int32;
    fAnimationName: string;
    fAnimation: TAnimation;
    fWidth: int32;
    fX: int32;
    fY: int32;
    fHeight: int32;
    procedure DoInitialize;
    procedure DoFinalize;
    procedure RecalculateVertices;
    procedure SetAnimationName(const Value: string);
    procedure SetFrame(const Value: int32);
    procedure SetHeight(const Value: int32);
    procedure SetWidth(const Value: int32);
    procedure SetX(const Value: int32);
    procedure SetY(const Value: int32);
    procedure RecalculateTexture;
    procedure SetSpriteSheet(const Value: TSpriteSheet);
  public
    constructor Create( aOwner: TSceneComponent ); override;
    destructor Destroy;

    procedure Render( Context: TViewportContext ); override;
  published
    property SpriteSheet: TSpriteSheet read fSpriteSheet write SetSpriteSheet;
    property X: int32 read fX write SetX;
    property Y: int32 read fY write SetY;
    property Width: int32 read fWidth write SetWidth;
    property Height: int32 read fHeight write SetHeight;
    property Animation: string read fAnimationName write SetAnimationName;
    property Frame: int32 read fFrame write SetFrame;
  end;

implementation
uses
  System.Math.Vectors, // for point3D
  System.Types; // for pointF

{ TSprite }

constructor TSprite.Create(aOwner: TSceneComponent);
begin
  inherited Create( aOwner );
  DoInitialize;
  Width := 200;
  Height := 200;
end;

destructor TSprite.Destroy;
begin
  DoFinalize;
  inherited Destroy;
end;

procedure TSprite.DoFinalize;
begin
  fMaterial.Free;
  fIndexBuffer.Free;
  fVertexBuffer.Free;
end;

procedure TSprite.DoInitialize;
begin
  fVertexBuffer := TVertexBuffer.Create([TVertexFormat.Vertex, TVertexFormat.TexCoord0], 4);
  fIndexBuffer := TIndexBuffer.Create(6);
  fMaterial := TTextureMaterial.Create;
  fAnimation := nil;
  // initial values
  fX := 0;
  fY := 0;
  fWidth := 0;
  fHeight := 0;
  RecalculateVertices;
  // Set Indices
  fIndexBuffer[0] := 0;
  fIndexBuffer[1] := 1;
  fIndexBuffer[2] := 3;
  fIndexBuffer[3] := 3;
  fIndexBuffer[4] := 1;
  fIndexBuffer[5] := 2;
  // Recalculate texture co-ords
  SpriteSheet := nil;
end;

procedure TSprite.RecalculateVertices;
begin
  fVertexBuffer.Vertices[0] := Point3D( X,        Y,         0);
  fVertexBuffer.Vertices[1] := Point3D( X+Width,  Y,         0);
  fVertexBuffer.Vertices[2] := Point3D( X+Width,  Y+Height,  0);
  fVertexBuffer.Vertices[3] := Point3D( X,        Y+Height,  0);
end;

procedure TSprite.Render(Context: TViewportContext);
begin
  if assigned(SpriteSheet) then begin
    Context.Context3D.DrawTriangles(fVertexBuffer,fIndexBuffer,fMaterial,1);
  end;
  inherited Render(Context); // draw children
end;

procedure TSprite.RecalculateTexture;
var
  X1: double;
  Y1: double;
  X2: double;
  Y2: double;
begin
  if assigned(fAnimation) then begin
    if (fAnimation.Count>0) then begin
      // Check that the frame index is valid
      if (fFrame<0) then begin
        fFrame := fAnimation.Count;
      end else if (fFrame>=fAnimation.Count) then begin
        fFrame := 0;
      end;
      // Copy the animation frame coordinates.
      X1 := fAnimation.Frames[fFrame].TexCoords.Left;
      Y1 := fAnimation.Frames[fFrame].TexCoords.Top;
      X2 := fAnimation.Frames[fFrame].TexCoords.Right;
      Y2 := fAnimation.Frames[fFrame].TexCoords.Bottom;
      fVertexBuffer.TexCoord0[0] := PointF(X1, Y1);
      fVertexBuffer.TexCoord0[1] := PointF(X2, Y1);
      fVertexBuffer.TexCoord0[2] := PointF(X2, Y2);
      fVertexBuffer.TexCoord0[3] := PointF(X1, Y2);
    end;
  end;
end;

procedure TSprite.SetAnimationName(const Value: string);
begin
  fAnimationName := Value;
  fAnimation := fSpriteSheet.AnimByName[Animation];
  fFrame := 0;
  RecalculateTexture;
end;

procedure TSprite.SetFrame(const Value: int32);
begin
  fFrame := Value;
  RecalculateTexture;
end;

procedure TSprite.SetHeight(const Value: int32);
begin
  fHeight := Value;
  RecalculateVertices;
end;

procedure TSprite.SetSpriteSheet(const Value: TSpriteSheet);
begin
  fSpriteSheet := Value;
  if assigned(SpriteSheet) then begin
    fMaterial.Texture := TContext3D.BitmapToTexture(SpriteSheet.Image);
    fAnimation := SpriteSheet.AnimByName[Animation];
  end else begin
    fAnimation := nil;
  end;
  fFrame := 0;
  RecalculateTexture;
end;

procedure TSprite.SetWidth(const Value: int32);
begin
  fWidth := Value;
  RecalculateVertices;
end;

procedure TSprite.SetX(const Value: int32);
begin
  fX := Value;
  RecalculateVertices;
end;

procedure TSprite.SetY(const Value: int32);
begin
  fY := Value;
  RecalculateVertices;
end;

end.

There you have it. Derived from the TSceneComponent that we wrote in the previous part, this TSprite component can easily be added to your scene through it’s constructor.
The sprite class has a ‘SpriteSheet’ property which provides the sprite with a reference to the image data, as well as the data describing the animation frames. The ‘Animation’ property is a string in which we put the name of the animation that we’d like to use from the sprite sheet, and the ‘Frame’ property indexes the relevant cell of animation to display. There are three methods of interest within the class, lets take a look at them.

procedure TSprite.RecalculateVertices;
begin
  fVertexBuffer.Vertices[0] := Point3D( X,        Y,         0);
  fVertexBuffer.Vertices[1] := Point3D( X+Width,  Y,         0);
  fVertexBuffer.Vertices[2] := Point3D( X+Width,  Y+Height,  0);
  fVertexBuffer.Vertices[3] := Point3D( X,        Y+Height,  0);
end;

RecalculateVertices is a private method which is called whenever the X or Y position, or Width or Height properties are changed. If we alter the position or size of our sprite, we need to adjust the surface that we display the sprite on, and that’s what we’re doing here. You’ll remember the vertex buffer from part 1, well its now a private member of our sprite class.

procedure TSprite.RecalculateTexture;
var
  X1: double;
  Y1: double;
  X2: double;
  Y2: double;
begin
  if assigned(fAnimation) then begin
    if (fAnimation.Count>0) then begin
      // Check that the frame index is valid
      if (fFrame<0) then begin
        fFrame := fAnimation.Count;
      end else if (fFrame>=fAnimation.Count) then begin
        fFrame := 0;
      end;
      // Copy the animation frame coordinates.
      X1 := fAnimation.Frames[fFrame].TexCoords.Left;
      Y1 := fAnimation.Frames[fFrame].TexCoords.Top;
      X2 := fAnimation.Frames[fFrame].TexCoords.Right;
      Y2 := fAnimation.Frames[fFrame].TexCoords.Bottom;
      fVertexBuffer.TexCoord0[0] := PointF(X1, Y1);
      fVertexBuffer.TexCoord0[1] := PointF(X2, Y1);
      fVertexBuffer.TexCoord0[2] := PointF(X2, Y2);
      fVertexBuffer.TexCoord0[3] := PointF(X1, Y2);
    end;
  end;
end;

RecalculateTexture is a private method which is called any time the animation is altered in some way. Perhaps we set a new sprite sheet, or name a new animation, or alter the frame number, in all of these cases we call RecalculateTexture so that it can reference the sprite sheet data to select the appropriate piece of animation to display. We’re not actually performing any calculations here, at this point in the code we’re expecting the calculations to have already been done when the animation frames were loaded into the sprite sheet. In fact, we’ll see how that’s done a little later.

Finally, there’s the render method:

procedure TSprite.Render(Context: TViewportContext);
begin
  if assigned(SpriteSheet) then begin
    Context.Context3D.DrawTriangles(fVertexBuffer,fIndexBuffer,fMaterial,1);
  end;
  inherited Render(Context); // draw children
end;

By now, it should be quite obvious to you what this method is doing. It’s rendering our two triangles onto the context, passing the vertex buffer and index buffer (each of which is a private member of this class and pre-calculated), and then the material (prepared from the sprite sheet), in order to draw our frame of animation. You’ll notice that we still have a dangling literal 1 as a parameter for the opacity setting, we could now easily expose that as a public/published property of TSprite. After drawing our animation frame, the Render method calls the inherited Render(), which you’ll recall causes the sprite to render any children that it has.

With these new classes, we’re finally able to render an animation in our application. Lets look at the steps required to build the sample application.

  1. Start a new Multidevice Delphi application (Firemonkey/FMX)
  2. Select a 3D application from the wizard.
  3. Drop a TViewport component onto your form.
  4. Drop a TSpriteScene component onto your form.
  5. Drop a TSpriteSheet component onto your form.
  6. Drop a TTimer component onto your form.
  7. Ensure Viewport1.Form is set to point at your main form.
  8. Set Viewport1.Scene to point at SpriteScene1
  9. Set the timer interval to 50
  10. Add unitSpriteSheet to your uses list.
  11. Add a private member to the form: “S: TSprite;”
  12. In the OnTimer event add “Viewport1.Render;  S.Frame := S.Frame + 1;”
  13. On the forms OnCreate add the following code..
    var
      i: int32;
    begin
      // Populate sprite sheet
      SpriteSheet1.Image.LoadFromFile('pigeon.png');
      i := SpriteSheet1.AddAnimation(TAnimation.Create('flap'));
      SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 1,   1,   165, 155));
      SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 168, 1,   165, 155));
      SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 335, 1,   165, 155));
      SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 502, 1,   165, 155));
      SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 1,   158, 165, 155));
      SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 1,   315, 165, 155));
      SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 1,   472, 165, 155));
      SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 1,   629, 165, 155));
      SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 168, 158, 165, 155));
      SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 335, 158, 165, 155));
      SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 502, 158, 165, 155));
      SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 168, 315, 165, 155));
      SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 168, 472, 165, 155));
      SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 168, 629, 165, 155));
      SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 335, 315, 165, 155));
      SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 502, 315, 165, 155));
      SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 335, 472, 165, 155));
      SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 335, 629, 165, 155));
      SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 502, 472, 165, 155));
      SpriteSheet1.Animation[i].AddFrame(TAnimationFrame.Create(SpriteSheet1.Image.Width, SpriteSheet1.Image.Height, 502, 629, 165, 155));
    
      // Create backdrop
      TBackdrop.Create(SpriteScene1.Root);
    
      // Create sprite
      S := TSprite.Create(SpriteScene1.Root);
      S.SpriteSheet := SpriteSheet1;
      S.Animation := 'flap';
      S.X := 100;
      S.Y := 100;
    end;
    

In order to make this sample work, you’ll need to drop the pigeon sprite sheet into the executable directory. You can download it here: pigeon.

So this new initialization in the form’s OnCreate event, what are we doing here?
We start by loading the pigeon.png file into the sprite sheet with SpriteSheet1.Image.LoadFromFile().
On the very next line, we initialize an animation named ‘flap’, and then on subsequent lines we add frames of animation.
Each frame is specified with six parameters:

  1. The width of the sprite sheet image. (SpriteSheet1.Image.Width)
  2. The height of the sprite sheet image, (SpriteSheet1.Image.Height)
  3. The X position (in pixels) of the animation cell within the SpriteSheet image.
  4. The Y position (in pixels) of the animation cell within the SpriteSheet image.
  5. The Width (in pixels) of the animation cell.
  6. The Height (in pixels) of the animation cell.

We then build up our scene by adding a TBackdrop, and a TSprite which we configure to use the SpriteSheet, the animation named ‘flap’, and we position the sprite at 100 x 100 within our scene.
Notice that we’re using the private class global member ‘S: TSprite;’ to keep a reference to our sprite. We’re doing this so that we can manually advance the sprite’s animation frame within the timer event.

Go ahead and run the application, you should see the pigeon flapping it’s wings….

pigeonflap

You can download the source for the sprite engine package, and the test application here: SpriteEngine

As an experiment, I went ahead and added two sprites, sharing the same sprite sheet and offset their locations. As you can see in the following image, the transparent regions of each sprite are correctly rendered. This demonstrates sharing the sprite resources among sprites, and that transparency is working, at least on windows!

twopigeon

So you now have a sprite engine which is able to clear the screen, and play animations from a sprite sheet. It’s a little rough-and-ready, but it works as a proof of concept. Lets see which of our goals we’ve accomplished…

  1.  Ability to display 2d images. [DONE]
  2. Support transparency (transparent zones). [DONE, works on windows, to test on others.]
  3. Supported on Mac OSX, Windows, Android and iOS. [DONE, test this.]
  4. Hierarchical scene composition [DONE]
  5. Scrolling backdrop component. [To Be Done]
  6. Scrolling tile map component. [To Be Done]
  7. Reuse of image resources to reduce memory costs. [DONE]
  8. Support for collision detection. [To Be Done]
  9. Must perform sufficiently to run a simple game. [To Be Done]

We’re a little over half way there! In my next post we’ll look at creating a scrolling backdrop of some kind. Until then…

Thanks for reading!

Facebooktwittergoogle_plusredditpinterestlinkedintumblrmail

1 Response

  1. 2017-11-11

    […] sprites for the objects I wanted and going to the image’s sites. I found the pigeon in chapmanworld.com . The backgrounds are simple images from google and the little crates I found in this great game […]

Leave a Reply