The reason for the offset is that the pre-rendered texture was being drawn upside-down. I'm still not certain why this is the case, but it explains the offset. I was able to determine this by modifying the textures so they had a recognizable sequence.
/EDIT
I have a program that is used to get a high-level view of the contents of microscope slides. We take a 512x512 image of one small area of the slide, move the slide over, take another image, and repeat until we can generate a mosaic that covers a comparatively large area. These mosaics are composed of thousands of individual tiles, and the user can pan and zoom all over. I'm working on improving its performance.
When I first saw this system it wasn't doing any optimizations. Every tile was drawn every frame, even when those tiles were out of the view. So my first change was to cull out-of-view tiles. This is great so long as the user is fairly well zoomed-in; at zoom levels of .1 or so you physically can't fit enough tiles onto the screen to cause noticeable slowdown. But if you zoom out further, then more and more tiles are in view, and simply rendering 4k tiles, even if they each only take up 50 pixels, causes significant slowdown.
Given this, I thought it'd make sense to pre-render the mosaic tiles at a fixed, zoomed-out scale. For example, if I have a 10x10 grid of 512x512 tiles, and I shrank them all down by a factor of 10, the result would fit onto a single 512x512 texture. Thus, any time I'm zoomed out by at least a factor of 10, I can render a single full-size texture instead of 100 downscaled textures. This should significantly improve performance.
I wrote up a test program to get the details hammered out, and I'm running into some trouble. This is my first time working with framebuffers, so I guess that's to be expected. I'm starting out with only a single pre-rendered texture that covers the entire view; thus, the pre-rendering logic should consist of "grab all tiles that fit entirely within the view and render them to the pre-rendered texture". My test pattern is a 12x12 grid of textures, 11x11 of which fit onto the canvas and the rest of which overlap the edge. I'm ending up with an offset between the tiles that fit and the ones that don't:
(Ignore the border on three edges of this image, which is caused by sloppy screenshotting technique)
As far as I can tell, the prerendered texture is using exactly the same rendering parameters as the normal rendering, so why the offset of 3/4ths of a tile's height? Here's the source code for my program. The drawing code is in onPaint (for rendering tiles that aren't pre-rendered, and for rendering the pre-rendered tiles), and in refreshCachedTextures, for pre-rendering tiles.
import numpyimport OpenGL.GL as GLimport OpenGL.GL.EXT.framebuffer_object as Framebufferimport randomimport tracebackimport wximport wx.glcanvas## Simple module to provide an OpenGL canvas for the testapp.## Size of one texture in the "mosaic", in pixelsSIZE = 384## Zoom factorZOOM = .1## Number of macro tiles to use, per edge (so total tiles = x^2). Macro tiles# are sized to completely cover the canvas with no overlap. NUMTILES = 1class TestCanvas(wx.glcanvas.GLCanvas): def __init__(self, parent, size, id = -1, *args, **kwargs): wx.glcanvas.GLCanvas.__init__(self, parent, id, size = size, *args, **kwargs) (self.width, self.height) = size self.canvasWidth = int(self.width / ZOOM) self.canvasHeight = int(self.height / ZOOM) ## Whether or not we have done some one-time-only logic. self.haveInitedGL = False ## Whether or not we should try to draw self.shouldDraw = True ## Framebuffer object for prerendered textures self.buffer = Framebuffer.glGenFramebuffersEXT(1) ## Array of textures to parcel up the view. self.cachedTextures = [] for x in xrange(NUMTILES): self.cachedTextures.append([]) for y in xrange(NUMTILES): self.cachedTextures[x].append( self.makeTexture(self.width / NUMTILES, self.height / NUMTILES)) self.timer = wx.Timer(self, -1) ## Maps all textures to their locations. self.allTextures = {} ## Maps new textures to their locations. self.newTextures = {} ## Maps textures that can't fit into one of self.cachedTextures to # their locations. self.outlierTextures = {} ## Data used to generate textures - a simple horizontal gradient. self.texArray = numpy.array(range(SIZE) * SIZE) / float(SIZE) self.texArray.shape = SIZE, SIZE self.texArray = numpy.array(self.texArray, dtype = numpy.float32) wx.EVT_PAINT(self, self.onPaint) wx.EVT_SIZE(self, lambda event: event) wx.EVT_ERASE_BACKGROUND(self, lambda event: event) # Do nothing, to avoid flashing wx.EVT_TIMER(self, self.timer.GetId(), self.onTimer) self.timer.Start(100) # Add a tile 10 times per second. random.seed(0) # Create a grid of tiles to start off. for x in xrange(0, self.canvasWidth, SIZE * 3 / 2): for y in xrange(0, self.canvasHeight, SIZE * 3 / 2): self.onTimer(shouldRefresh = False, pos = (x, y)) ## Set up some set-once things for OpenGL. def initGL(self): (self.width, self.height) = self.GetClientSizeTuple() self.SetCurrent() GL.glClearColor(0.0, 0.0, 0.0, 0.0) ## Add a tile. def onTimer(self, event = None, shouldRefresh = True, pos = None): if pos is None: x = random.uniform(0, self.canvasWidth) y = random.uniform(0, self.canvasHeight) return else: (x, y) = pos texture = self.makeTexture(SIZE, SIZE, self.texArray.tostring()) self.newTextures[texture] = (x, y) if len(self.newTextures) % 10 == 0: print "Made texture",(len(self.allTextures) + len(self.newTextures)) if shouldRefresh: self.Refresh() ## Create a texture def makeTexture(self, width, height, data = None): if data is None: data = numpy.ones((width, height), dtype = numpy.float32) texture = GL.glGenTextures(1) GL.glBindTexture(GL.GL_TEXTURE_2D, texture) GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR) GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR) GL.glTexImage2D(GL.GL_TEXTURE_2D, 0, GL.GL_RGB, width, height, 0, GL.GL_LUMINANCE, GL.GL_FLOAT, data) return texture def onPaint(self, event = None): if not self.shouldDraw: return try: if not self.haveInitedGL: self.initGL() self.haveInitedGL = True if len(self.newTextures) > 10: self.refreshCachedTextures() dc = wx.PaintDC(self) self.SetCurrent() GL.glViewport(0, 0, self.width, self.height) GL.glMatrixMode(GL.GL_PROJECTION) GL.glLoadIdentity() GL.glOrtho(0, self.width, self.height, 0, 0, 1) GL.glScalef(ZOOM, ZOOM, 1) GL.glMatrixMode(GL.GL_MODELVIEW) GL.glClear(GL.GL_COLOR_BUFFER_BIT, GL.GL_DEPTH_BUFFER_BIT) GL.glEnable(GL.GL_TEXTURE_2D) # Draw the cached textures self.drawTexture(self.cachedTextures[0][0], (0, 0), (self.canvasWidth, self.canvasHeight))# macroTileWidth = self.canvasWidth / NUMTILES# macroTileHeight = self.canvasHeight / NUMTILES# for x in xrange(NUMTILES):# for y in xrange(NUMTILES):# texture = self.cachedTextures[x][y]# pos = (x * macroTileWidth, y * macroTileHeight)# self.drawTexture(texture, pos, # (self.canvasWidth / float(NUMTILES), # self.canvasHeight / float(NUMTILES))# )# GL.glColor3f(1, 0, 0)# GL.glBegin(GL.GL_LINE_STRIP)# for offX, offY in [(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)]:# GL.glVertex2f(pos[0] + offX * macroTileWidth,# pos[1] + offY * macroTileHeight)# GL.glEnd()# GL.glColor3f(1, 1, 1) for texture, pos in dict(self.outlierTextures, **self.newTextures).iteritems(): self.drawTexture(texture, pos, (SIZE, SIZE)) GL.glFlush() self.SwapBuffers() except Exception, e: print "Exception:",e traceback.print_exc() self.shouldDraw = False def drawTexture(self, texture, pos, size): GL.glBindTexture(GL.GL_TEXTURE_2D, texture) GL.glBegin(GL.GL_QUADS) for offX, offY in [(0, 0), (1, 0), (1, 1), (0, 1)]: GL.glTexCoord2f(offX, offY) GL.glVertex2f(pos[0] + offX * size[0], pos[1] + offY * size[1]) GL.glEnd() GL.glColor3f(1, 0, 0) GL.glBegin(GL.GL_LINE_STRIP) for offX, offY in [(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)]: a = -1 if offX else 1 b = -1 if offY else 1 GL.glVertex2f(pos[0] + offX * size[0] + a, pos[1] + offY * size[1] + b) GL.glEnd() GL.glColor3f(1, 1, 1) ## Render new tiles to self.cachedTextures so we don't have to render # them individually. def refreshCachedTextures(self): # Determine which new textures fit into each of our cached textures. for i in xrange(NUMTILES): minX = i / float(NUMTILES) * self.canvasWidth maxX = minX + self.canvasWidth / float(NUMTILES) for j in xrange(NUMTILES): minY = j / float(NUMTILES) * self.canvasHeight maxY = minY + self.canvasHeight / float(NUMTILES) target = self.cachedTextures[j] targetTextures = {} # Find new textures that belong in this one's area. for texture, (x, y) in self.newTextures.iteritems(): if x > minX and x < maxX and y > minY and y < maxY: # Upper-left corner is in us. Check if lower-right # matches; if not, this texture will never fit and # goes into self.outlierTextures if x + SIZE < maxX and y + SIZE < maxY: targetTextures[texture] = (x, y) else: self.outlierTextures[texture] = (x, y) if targetTextures: # We have new textures to render in this block. Framebuffer.glBindFramebufferEXT( Framebuffer.GL_FRAMEBUFFER_EXT, target) Framebuffer.glFramebufferTexture2DEXT( Framebuffer.GL_FRAMEBUFFER_EXT, Framebuffer.GL_COLOR_ATTACHMENT0_EXT, GL.GL_TEXTURE_2D, target, 0) GL.glViewport(i * self.width / NUMTILES, j * self.height / NUMTILES, self.width / NUMTILES, self.height / NUMTILES) GL.glMatrixMode(GL.GL_PROJECTION) GL.glLoadIdentity() GL.glOrtho(0, self.width, self.height, 0, 0, 1) GL.glScalef(ZOOM, ZOOM, 1) GL.glMatrixMode(GL.GL_MODELVIEW) GL.glEnable(GL.GL_TEXTURE_2D) for texture, pos in targetTextures.iteritems(): self.drawTexture(texture, pos, (SIZE, SIZE)) Framebuffer.glBindFramebufferEXT(Framebuffer.GL_FRAMEBUFFER_EXT, 0) self.allTextures.update(self.newTextures) self.newTextures = {}class DemoFrame(wx.Frame): def __init__(self): wx.Frame.__init__(self, parent = None, size = (700, 700)) sizer = wx.BoxSizer(wx.HORIZONTAL) sizer.Add(TestCanvas(self, (700, 700))) self.SetSizerAndFit(sizer)class App(wx.App): def OnInit(self): import sys self.frame = DemoFrame() self.frame.Show() self.SetTopWindow(self.frame) return Trueapp = App(redirect = False)app.MainLoop()
Incidentally, I discovered an optical illusion while working on this:
[Edited by - Derakon on November 15, 2010 2:28:48 PM]