Pixel-art Sprite Cards

For my interactive art portfolio, I want to insert some pixel art sprites into my scene. However just loading the textures outright doesn’t quite get the look required for pixelart so I’ve started work on pixel art sprite nodes. Might also be useful for anyone who wants to do a 2.5D game in Panda. Still early progress, I want to add animations, sprite flipping and sprite sheets to this later.

This class is public domain

from pandac.PandaModules import NodePath, PNMImageHeader, PNMImage, Filename, CardMaker, TextureStage, Texture, TransparencyAttrib
from math import log, modf

class Sprite2d:

    class Cell:
        def __init__(self, col, row):
            self.col = col
            self.row = row
        
        def __str__(self):
            return "Cell - Col %d, Row %d" % (self.col, self.row)
        
    class Animation:
        def __init__(self, cells, fps):
            self.cells = cells
            self.fps = fps
            self.playhead = 0

    ALIGN_CENTER = "Center"
    ALIGN_LEFT = "Left"
    ALIGN_RIGHT = "Right"
    ALIGN_BOTTOM = "Bottom"
    ALIGN_TOP = "Top"
    
    TRANS_ALPHA = TransparencyAttrib.MAlpha
    TRANS_DUAL = TransparencyAttrib.MDual
    # One pixel is divided by this much. If you load a 100x50 image with PIXEL_SCALE of 10.0
    # you get a card that is 1 unit wide, 0.5 units high
    PIXEL_SCALE = 10.0

    def __init__(self, image_path, name=None,\
                  rows=1, cols=1, scale=1.0,\
                  twoSided=True, alpha=TRANS_ALPHA,\
                  repeatX=1, repeatY=1,\
                  anchorX=ALIGN_LEFT, anchorY=ALIGN_BOTTOM):
        """
        Create a card textured with an image. The card is sized so that the ratio between the
        card and image is the same.
        """
        
        scale *= self.PIXEL_SCALE
        
        self.animations = {}
        
        self.scale = scale
        self.repeatX = repeatX
        self.repeatY = repeatY
        self.flip = {'x':False,'y':False}
        self.rows = rows
        self.cols = cols
        
        self.currentFrame = 0
        self.currentAnim = None
        self.loopAnim = False
        self.frameInterrupt = True
        
        # Create the NodePath
        if name:
            self.node = NodePath("Sprite2d:%s" % name)
        else:
            self.node = NodePath("Sprite2d:%s" % image_path)
        
        # Set the attribute for transparency/twosided
        self.node.node().setAttrib(TransparencyAttrib.make(alpha))
        if twoSided:
            self.node.setTwoSided(True)
        
        # Make a filepath
        self.imgFile = Filename(image_path)
        if self.imgFile.empty():
            raise IOError, "File not found"
        
        # Instead of loading it outright, check with the PNMImageHeader if we can open
        # the file.
        imgHead = PNMImageHeader()
        if not imgHead.readHeader(self.imgFile):
            raise IOError, "PNMImageHeader could not read file. Try using absolute filepaths"
        
        # Load the image with a PNMImage
        image = PNMImage()
        image.read(self.imgFile)
        
        self.sizeX = image.getXSize()
        self.sizeY = image.getYSize()
        
        self.frames = []
        for rowIdx in xrange(self.rows):
            for colIdx in xrange(self.cols):
                self.frames.append(Sprite2d.Cell(colIdx, rowIdx))
        
        # We need to find the power of two size for the another PNMImage
        # so that the texture thats loaded on the geometry won't have artifacts
        textureSizeX = self.nextsize(self.sizeX)
        textureSizeY = self.nextsize(self.sizeY)
        
        # The actual size of the texture in memory
        self.realSizeX = textureSizeX
        self.realSizeY = textureSizeY
        
        self.paddedImg = PNMImage(textureSizeX, textureSizeY)
        if image.hasAlpha():
            self.paddedImg.alphaFill(0)
        # Copy the source image to the image we're actually using
        self.paddedImg.blendSubImage(image, 0, 0)
        # We're done with source image, clear it
        image.clear()
        
        # The pixel sizes for each cell
        self.colSize = self.sizeX/self.cols
        self.rowSize = self.sizeY/self.rows
        
        # How much padding the texture has
        self.paddingX = textureSizeX - self.sizeX
        self.paddingY = textureSizeY - self.sizeY
        
        # Set UV padding
        self.uPad = float(self.paddingX)/textureSizeX
        self.vPad = float(self.paddingY)/textureSizeY
        
        # The UV dimensions for each cell
        self.uSize = (1.0 - self.uPad) / self.cols
        self.vSize = (1.0 - self.vPad) / self.rows
        
        card = CardMaker("Sprite2d-Geom")

        # The positions to create the card at
        if anchorX == self.ALIGN_LEFT:
            posLeft = 0
            posRight = (self.colSize/scale)*repeatX
        elif anchorX == self.ALIGN_CENTER:
            posLeft = -(self.colSize/2.0/scale)*repeatX
            posRight = (self.colSize/2.0/scale)*repeatX
        elif anchorX == self.ALIGN_RIGHT:
            posLeft = -(self.colSize/scale)*repeatX
            posRight = 0
        
        if anchorY == self.ALIGN_BOTTOM:
            posTop = 0
            posBottom = (self.rowSize/scale)*repeatY
        elif anchorY == self.ALIGN_CENTER:
            posTop = -(self.rowSize/2.0/scale)*repeatY
            posBottom = (self.rowSize/2.0/scale)*repeatY
        elif anchorY == self.ALIGN_TOP:
            posTop = -(self.rowSize/scale)*repeatY
            posBottom = 0
        
        card.setFrame(posLeft, posRight, posTop, posBottom)
        card.setHasUvs(True)
        self.card = self.node.attachNewNode(card.generate())
        
        # Since the texture is padded, we need to set up offsets and scales to make
        # the texture fit the whole card
        self.offsetX = (float(self.colSize)/textureSizeX)
        self.offsetY = (float(self.rowSize)/textureSizeY)
        
        self.node.setTexScale(TextureStage.getDefault(), self.offsetX * repeatX, self.offsetY * repeatY)
        self.node.setTexOffset(TextureStage.getDefault(), 0, 1-self.offsetY)
        
        self.texture = Texture()
        
        self.texture.setXSize(textureSizeX)
        self.texture.setYSize(textureSizeY)
        self.texture.setZSize(1)
        
        # Load the padded PNMImage to the texture
        self.texture.load(self.paddedImg)

        self.texture.setMagfilter(Texture.FTNearest)
        self.texture.setMinfilter(Texture.FTNearest)
        
        #Set up texture clamps according to repeats
        if repeatX > 1:
            self.texture.setWrapU(Texture.WMRepeat)
        else:
            self.texture.setWrapU(Texture.WMClamp)
        if repeatY > 1:
            self.texture.setWrapV(Texture.WMRepeat)
        else:
            self.texture.setWrapV(Texture.WMClamp)
        
        self.node.setTexture(self.texture)
    
    def nextsize(self, num):
        """ Finds the next power of two size for the given integer. """
        p2x=max(1,log(num,2))
        notP2X=modf(p2x)[0]>0
        return 2**int(notP2X+p2x)
    
    def setFrame(self, frame=0):
        """ Sets the current sprite to the given frame """
        self.frameInterrupt = True # A flag to tell the animation task to shut it up ur face
        self.currentFrame = frame
        self.flipTexture()
    
    def playAnim(self, animName, loop=False):
        """ Sets the sprite to animate the given named animation. Booleon to loop animation"""
        if hasattr(self, "task"):
            if not self.task.isRemoved():
                taskMgr.remove(self.task)
        self.frameInterrupt = False # Clear any previous interrupt flags
        self.loopAnim = loop
        self.currentAnim = self.animations[animName]
        self.currentAnim.playhead = 0
        self.task = taskMgr.doMethodLater(1.0/self.currentAnim.fps,self.animPlayer, "Animate sprite")
    
    def createAnim(self, animName, frames, fps=12):
        """ Create a named animation. Takes the animation name and a tuple of frame numbers """
        self.animations[animName] = Sprite2d.Animation(frames, fps)
        return self.animations[animName]
    
    def flipX(self, val=None):
        """ Flip the sprite on X. If no value given, it will invert the current flipping."""
        if val:
            self.flip['x'] = val
        else:
            if self.flip['x']:
                self.flip['x'] = False
            else:
                self.flip['x'] = True
        self.flipTexture()
        return self.flip['x']
        
    def flipY(self, val=None):
        """ See flipX """
        if val:
            self.flip['y'] = val
        else:
            if self.flip['y']:
                self.flip['y'] = False
            else:
                self.flip['y'] = True
        self.flipTexture()
        return self.flip['y']

    def flipTexture(self):
        """ Sets the texture coordinates of the texture to the current frame"""
        sU = self.offsetX * self.repeatX
        sV = self.offsetY * self.repeatY
        oU = 0 + self.frames[self.currentFrame].col * self.uSize
        oV = 1 - self.frames[self.currentFrame].row * self.vSize - self.offsetY
        if self.flip['x']:
            sU *= -1
            oU = self.uSize + self.frames[self.currentFrame].col * self.uSize
        if self.flip['y']:
            sV *= -1
            oV = 1 - self.frames[self.currentFrame].row * self.vSize
        self.node.setTexScale(TextureStage.getDefault(), sU, sV)
        self.node.setTexOffset(TextureStage.getDefault(), oU, oV)
    
    def clear(self):
        """ Free up the texture memory being used """
        self.texture.clear()
        self.paddedImg.clear()
        self.node.removeNode()
    
    def animPlayer(self, task):
        if self.frameInterrupt:
            return task.done
        #print "Playing",self.currentAnim.cells[self.currentAnim.playhead]
        self.currentFrame = self.currentAnim.cells[self.currentAnim.playhead]
        self.flipTexture()
        if self.currentAnim.playhead+1 < len(self.currentAnim.cells):
            self.currentAnim.playhead += 1
            return task.again
        if self.loopAnim:
            self.currentAnim.playhead = 0
            return task.again

Usage:

Sprite2d(image_path, rows=<rows on sprite sheet>, cols=<columns on sprite sheet>, scale=<optional scale factor>, 
                  twoSided=<option for twosided rendering>, alpha=<TransparencyAttrib>, repeatX=<stretch the card by X, not very compatible with sprite sheets>, repeatY=1,\
                  anchorX=<"Left, Center, Right">, anchorY=<"top, center, bottom">)

To manipulate the node, use Sprite2d.node like so:
Sprite2d.node.reparentTo(<node>)
Sprite2d.node.setPos(100, 20, 0)

Sprite2d.setFrame(frame)
Sprite sheets get their sprites numbered like so:
-------
|0|1|2|
-------
|3|4|5|
-------
Use the sprite number to set the frame

Sprite2d.createAnim(AnimName, Frames, FPS)
Create a named animation. Frames is a tuple of frame numbers for the animation. FPS is frames per second.

Sprite2d.playAnim(AnimName)
Playback the animation you've created with createAnim

Sprite2d.clear()
Clear the texture and pnmimage memory being used and remove the node.

1 Like

hey nice pixie retro stile!
keep up the good stuff

This is cleaner :

def nextsize(self, num):
    p2x=max(1,math.log(num,2))
    notP2X=math.modf(p2x)[0]>0
    return 2**int(notP2X+p2x)

Thanks ynhjh_jo, I was looking at a similar algorithm while I was doing the class but my sleep starved brain couldn’t figure out how to implement it.

The updated class now loads spritesheets and can playback animations.

Hi I’m new using panda3d, I’m trying to see the engine capabilities to mix 2d and 3d. This class seems to be everything I’ll need for the project. However, I have trouble integrating a Sprite2d object in order to display it or render it.

This Sprite2d object is supposed to be loaded as an actor or as a model, or as a texture?

Could anyone help me a little bit with this?

Nice result !

I already solve the problem. I needed to rtfm and realize that a NodePath can be reparented to the render root. Thus, I just reparented like this:

mySprite.node.reparentTo(render)

Please correct me if I’m doing it wrong or if there’s a better (correct) way of displaying it.

Oh, thanks for pointing out that lack of documentation to me. Somehow I missed that people would need to know how to reparent it to their scenegraph.

I have been using this class for a few of my projects for college and I have found it to be a great piece of code.

However, I discovered a bug with the animation task where it can only play one animation at a time:

def playAnim(self, animName, loop=False): 
        """ Sets the sprite to animate the given named animation. Booleon to loop animation""" 
        if not taskMgr.hasTaskNamed("Animate sprite"):
            if hasattr(self, "task"): 
                    taskMgr.remove("Animate sprite") 
                    del self.task
            self.frameInterrupt = False # Clear any previous interrupt flags 
            self.loopAnim = loop 
            self.currentAnim = self.animations[animName] 
            self.currentAnim.playhead = 0 
            self.task = taskMgr.doMethodLater(1.0/self.currentAnim.fps,self.animPlayer, "Animate sprite")

What you had going there was that the class only allowed one animation task, “Animate sprite”, for all instances of the Sprite2d class running in the program. So, if one was to have an animated avatar as well as a few animated enemies, only ONE of those instances of Sprite2d would animate.

The fix is simple:

def playAnim(self, animName, loop=False): 
        """ Sets the sprite to animate the given named animation. Booleon to loop animation""" 
        if not taskMgr.hasTaskNamed("Animate sprite" + self.spriteNum):
            if hasattr(self, "task"): 
                    taskMgr.remove("Animate sprite" + self.spriteNum) 
                    del self.task
            self.frameInterrupt = False # Clear any previous interrupt flags 
            self.loopAnim = loop 
            self.currentAnim = self.animations[animName] 
            self.currentAnim.playhead = 0 
            self.task = taskMgr.doMethodLater(1.0/self.currentAnim.fps,self.animPlayer, "Animate sprite" + self.spriteNum)

self.spriteNum is a user defined string that is unique to that instance of Sprite2d. The user should define it as a parameter to the constructor of Sprite2d.

With that fix, you can have multiple animated sprites.

Thanks for this i been looking for this my project…

Hi I feel bad posting a question that everyone else seems to know the answer to, but I’m stumped trying to get this to work. This is my first day trying to use Panda3d so maybe there’s something I haven’t learnt yet.

I’m just trying to display a red square using the sprite2d class with this tiny test program but I keep getting the error
“PNMImageHeader could not read file. Try using absolute filepaths”

Obviously the first thing I did was try using absolute filepaths, but it made no difference.

I then tested to make sure the files were there using onscreenimage, and that displays the square just fine.

Here’s my code…

from direct.showbase.ShowBase import ShowBase
import sprite2d

from direct.gui.OnscreenImage import OnscreenImage

class MyApp(ShowBase):
 
    def __init__(self):
        ShowBase.__init__(self)
		
        #imageObject = OnscreenImage(image = 'red.png', pos = (-0.5, 0, 0.02))

        self.sprite = sprite2d.Sprite2d('red.png', rows=1, cols=1) 
		
		#tried absolute path, no success
        #self.sprite = sprite2d.Sprite2d('C:\\Panda3D-1.7.2\\gamethree\\red.png', rows=1, cols=1) 
 
app = MyApp()
app.run()

I’ve got the class in sprite2d.py

Can anyone tell me what I’m doing wrong?

By “absolute filepaths,” it means Panda-style paths, not Windows-style paths.

So, the correct line should look like this:

self.sprite = sprite2d.Sprite2d('/c/Panda3D-1.7.2/gamethree/red.png', rows=1, cols=1)

David

Awesome, i’ll take this code to make some cool stuff :slight_smile: