Shader assisted real time painting

I need to create a real time painting game and the other methods for model painting on the forum are a bit too slow for my purpose so I was forced to figure out how to solve this in shaders. Much of this code is built on the offscreen buffer samples and the texture painting samples that are scattered around in the forums. This snippet is public domain.

Main python test file.

from direct.showbase.DirectObject import *
from pandac.PandaModules import Texture, PNMImage, PNMImageHeader, GraphicsOutput, NodePath, Filename,\
                                TextureStage, CardMaker, Vec3, PandaNode, Point3, WindowProperties,\
                                FrameBufferProperties, GraphicsPipe
from direct.interval.IntervalGlobal import *

class EditTexture:

    def __init__(self, node_path=None, image_path=None):
        self.texture = Texture()
        self.image = PNMImage()
        
        if node_path:
            node_textures = node_path.findAllTextures()
            node_textures.removeDuplicateTextures()
            self.texture = node_textures.getTexture(node_textures.getNumTextures()-1)
            self.texture.store(self.image)
            
            self.relatedNode = node_path
            self.relatedNode.setPythonTag("EditTexture", self)
        
        if image_path:
            # Make a filepath
            self.originalFile = Filename(image_path)
            if self.originalFile.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"
            
            self.image.read(self.originalFile)
        
        self.reload()
    
    def reload(self):
        self.texture.load(self.image)

class ShaderPaintTest(DirectObject):

    DRAW_CYCLE_TIME = 0.5

    def __init__(self):
        
        base.disableMouse()
        
        self.smiley = loader.loadModel('models/smiley')
        self.smileTex = EditTexture(self.smiley)
        self.smiley.reparentTo(render)
        self.smiley.setZ(10)
        
        base.camera.setPos(0, -10, 15)
        base.camera.lookAt(0,0,8)
        
        smileTurn = LerpHprInterval(self.smiley, 5, Vec3(360,0,0))
        smileTurn.loop()
        
        taskMgr.add(self.paintTask, "Paint Task")
        
        self.isPainting = False
        self.accept('mouse1', setattr, [self, 'isPainting', True])
        self.accept('mouse1-up', setattr, [self, 'isPainting', False])
        
        self.pickLayer = PNMImage()
        
        self.windowX = base.win.getXSize()
        self.windowY = base.win.getYSize()
        
        self.accept('v', base.bufferViewer.toggleEnable)
        self.accept('V',base.bufferViewer.toggleEnable)
        
        self.lastPaintTime = 0
        
        self.makeBuffers()
    
    def makeBuffers(self):
    
        paintBuffer = self.createOffscreenBuffer(-3, 1024, 1024)
        self.paintMap = Texture()
        paintBuffer.addRenderTexture(self.paintMap, GraphicsOutput.RTMBindOrCopy, GraphicsOutput.RTPColor)
        
        self.paintCam = base.makeCamera(paintBuffer, lens=base.cam.node().getLens())
        
        loadPaintNode = NodePath(PandaNode('paintnode'))
        loadPaintNode.setShader(loader.loadShader('painter.sha'))

        # Divide the texture size by 1000 cause shader inputs must be in float
        self.smiley.setShaderInput('texsize', 512, 256, 0, 0)
        self.paintCam.node().setInitialState(loadPaintNode.getState())
        
        # Create a small buffer for the shader program that will fetch the point from the texture made
        # by the paintBuffer
        
        #self.getPointBuffer = self.createOffscreenBuffer(-2, 2, 2)
        self.pointTex = Texture()
        #self.getPointBuffer.addRenderTexture(self.pointTex, GraphicsOutput.RTMBindOrCopy, GraphicsOutput.RTPColor)
        getPointBuffer = base.win.makeTextureBuffer("pick buffer", 2, 2, self.pointTex, True)
        
        self.pointScene = NodePath('point scene')
        
        self.pointCam = base.makeCamera(getPointBuffer, lens=base.cam.node().getLens())
        self.pointCam.reparentTo(self.pointScene)
        self.pointCam.setY(-2)
        
        cm = CardMaker('pointer scene card')
        cm.setFrameFullscreenQuad()
        pointCard = self.pointScene.attachNewNode(cm.generate())
        
        loadPoint = NodePath(PandaNode('pointnode'))
        loadPoint.setShader(loader.loadShader('pointer.sha'))
        
        # Feed the paintmap from the paintBuffer to the shader and initial mouse positions
        self.pointScene.setShaderInput('paintmap', self.paintMap)
        self.pointScene.setShaderInput('mousepos', 0, 0, 0, 1)
        self.pointCam.node().setInitialState(loadPoint.getState())
        
        self.pickLayer = PNMImage()
        
    def createOffscreenBuffer(self, sort, xsize, ysize):
        winprops = WindowProperties.size(xsize,ysize)
        props = FrameBufferProperties()
        props.setRgbColor(1)
        props.setAlphaBits(1)
        props.setDepthBits(1)
        return base.graphicsEngine.makeOutput(
            base.pipe, "offscreenBuffer",
            sort, props, winprops,
            GraphicsPipe.BFRefuseWindow,
            base.win.getGsg(), base.win)

    def paintTask(self, task):
        if not base.mouseWatcherNode.hasMouse():
            return task.cont

        if self.isPainting is False:
            return task.cont
        
        mpos = base.mouseWatcherNode.getMouse()
        x_ratio = min( max( ((mpos.getX()+1)/2), 0), 1)
        y_ratio = min( max( ((mpos.getY()+1)/2), 0), 1)
        mx = int(x_ratio*self.windowX)
        my = self.windowY - int(y_ratio*self.windowY)
        
        print mx, my
        print "Mouse", x_ratio, y_ratio
        
        self.pointScene.setShaderInput('mousepos', x_ratio, y_ratio, 0, 1)
        
        self.pointTex.store(self.pickLayer)
        
        # get the color below the mousepick from the rendered frame
        r = self.pickLayer.getRedVal(0,0)
        g = self.pickLayer.getGreenVal(0,0)
        b = self.pickLayer.getBlueVal(0,0)
        # calculate uv-texture position from the color
        x = r + ((b%16)*256)
        y = g + ((b//16)*256)
        
        for i in range(6):
            ny = i + y
            if ny < self.smileTex.texture.getYSize():
                for j in range(6):
                    nx = j + x
                    if nx < self.smileTex.texture.getXSize():
                        self.smileTex.image.setXel(nx,ny,255,0,0)
        self.smileTex.reload()
        print "UV Coordinates:", x, y
        
        return task.cont

if __name__ == "__main__":
    import direct.directbase.DirectStart
    s = ShaderPaintTest()
    run()
    sys.exit()

painter.sha - this generates the UV picking texture. The maths can probably be optimized but I’m confused on this.

//Cg

void vshader(
    uniform float4x4 mat_modelproj,
    in float4 vtx_position : POSITION,
    in float2 vtx_texcoord0 : TEXCOORD0,
    out float2 l_my : TEXCOORD0,
    out float4 l_position : POSITION)
{
    l_position = mul(mat_modelproj, vtx_position);
    l_my = vtx_texcoord0;
}

void fshader(
    uniform float4 k_texsize,
    in float2 l_my : TEXCOORD0,
    out float4 o_color : COLOR)
{
    int x = int((k_texsize.r) * l_my.x);
    int y = int(k_texsize.g)-int((k_texsize.g) * l_my.y);
    float r = float((x%256)/float(255));
    float g = float((y%256)/float(255));
    float b = float( (int(x/256) + int(y/256) * 16) / float(255) );
    o_color = float4(r, g, b, 1);
}

pointer.sha - Extracts the colour from the output of painter.sha so the X/Y values in the texture can be figured out

//Cg

void vshader(
    uniform float4x4 mat_modelproj,
    in float4 vtx_position : POSITION,
    in float2 vtx_texcoord0 : TEXCOORD0,
    out float2 l_my : TEXCOORD0,
    out float4 l_position : POSITION)
{
    l_position = mul(mat_modelproj, vtx_position);
    l_my = vtx_texcoord0;
}

void fshader(
    uniform sampler2D k_paintmap,
    uniform float4 k_mousepos,
    out float4 o_color : COLOR)
{
    o_color = tex2D(k_paintmap, float2(k_mousepos.r, k_mousepos.g));
}

i am not sure if the vertex painter is any faster the if you just replace the texture of the model by another texture.

one thing that you may improve for sure is by using the pnmpainter. try something like this:

workLayer = PNMImage()
# parameters are color, radius, smooth
brush = PNMBrush.makeSpot(VBase4D(1, 0, 0, 1), 7, True)
painter = PNMPainter(workLayer)
painter.setPen(brush)
painter.drawPoint(x, y)

here’s some working class using the pnmpainter:
http://code.google.com/p/panda3d-editor/source/browse/trunk/src/core/pTexturePainter.py?spec=svn299&r=299

Thanks, I’ll look into using PNMPainter. It looks very useful for what I’m planning to do with this.