Say you want to render something using Field's graphics system, but process the output before it goes on the screen? Perhaps you are projecting onto something that isn't flat, or from an odd angle; perhaps you want to composite one rendering with another; perhaps you are building your own passive stereo system and want to merge two views of the same scene into one screen. This page will get you started.
In this dive into Field's graphics system we'll touch upon the following topics:
We'll build and revise the code at each step; if you get lost, or don't fancy copy-pasting the code, you can just download the field sheet here.
Here's where we are heading:
Inside that heavily distorted rectangle, there's a full 3d scene — camera, geometry, shaders, the works. It just happens to be being drawn not to a rectangular screen, but to a warped shape. This shape is defined by the control vertices (those squares) and you can click and drag those shapes around (or modify them in code) to define the warp.
To do this we need to introduce an indirection — we render our scene not to the screen, but to some other area of memory and then use that to texture a piece of geometry. We have a lot of choices about how to do this, but we'll choose the simplest version here. First let's just draw a dark grey square — anybody who has played with the Field graphics system will recognize the default (almost subliminal) shader:
canvas = makeFullscreenCanvas()
shader = makeShaderFromElement(_self)
mesh = quadContainer()
canvas << shader << mesh
with mesh:
mesh ** mesh ** [Vector3(-1,-1,0), Vector3(1,-1,0), Vector3(1,1,0), Vector3(-1,1,0)]
Note how this connects together — canvas << shader << mesh
. The canvas has a shader connected to it (the default one). This shader shades a container for quads called 'mesh', into which we stick a single square. Now Field is drawing:
In a separate box, let's have something much more exciting:
lines = renderShader.getOnCanvasLines(canvas)
lines.submit.clear()
for n in floatRange(0, 40, 100):
a = Vector3().noise(2)
b = Vector3().noise(2)
c = Vector3().noise(2)
line = FLine().moveTo(*a).lineTo(*b).lineTo(*c)
line += Vector3(-20+n, 0, 0)
lines.submit.add(line)
line(filled=1, color=Vector4(Math.random(),Math.random(), 0, 0.2))
canvas << renderShader
Similar setup, but here we are using the FLine
drawing system. Yields:
Now, what we really want is to texture that boring grey square with those colorful triangles. First we need a Frame Buffer Object — OpenGL's name for an off-screen drawing area. First we make one:
fbo = makeFrameBuffer(canvas.width(), canvas.height(), useRect=0, genMipmaps=0)
(for a discussion about useRect
and other parameters, see here). And then we tell it to update along side the canvas:
canvas ** fbo
BaseGraphicsSystem_FBO explains the ** and << operators in depth.
Next we need to disconnect those colorful triangles from the canvas and put them into that FBO instead:
canvas | renderShader # --- disconnect
fbo.getSceneList() << renderShader # --- connect
You see that they disappear from the canvas completely. They are being drawn, just not to a place that you can see.
Two things remain. First we need to add the FBO as a texture map to the grey square:
shader.theTexture = 0
shader << fbo
That declares that we can use a texture called theTexture
in our shader. Let's use it. In our vertex shader:
varying vec4 vertexColor;
varying vec2 texCoord;
attribute vec4 s_Color;
attribute vec4 s_Five;
void main()
{
gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;
vertexColor = s_Color;
texCoord = s_Five.xy;
}
The plan is to grab our texture coords from channel "5".
The fragment shader will use them to do the texturing:
varying vec4 vertexColor;
varying vec2 texCoord;
uniform sampler2D theTexture;
void main()
{
gl_FragColor = texture2D(theTexture, texCoord.xy) + vec4(0.1, 0.1, 0, 0);
}
We've added a little dark yellow to the rendering so that we can see the edge of our rendering square.
With texture coords, our geometry code is now:
with mesh:
mesh ** mesh ** [Vector3(-1,-1,0), Vector3(1,-1,0), Vector3(1,1,0), Vector3(-1,1,0)]
mesh.setAux(0, 5, 0, 0)
mesh.setAux(1, 5, 1, 0)
mesh.setAux(2, 5, 1, 1)
mesh.setAux(3, 5, 0, 1)
We all those things in place we have:
That square is floating in the middle of the canvas. We can cause it to stick itself to the corners of the window instead by changing the vertex shader:
gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;
should become just:
gl_Position = gl_Vertex;
Now we have our vertex coordinates specified in normalized device coordinates — which is how OpenGL thinks about the screen. Specifically the bottom left corner is '-1,-1' and the top right is '1,1' — note that this is upside down from most windowing systems (that have their origin in the top, since historically, that's where the electron beam that scanned out the display started from), but it's the right way around for most grade-schoolers drawing graphs with squared paper (the x-axis does indeed start in the middle and head to the right, the y-axis does go up).
We've already got our mesh with a square from -1, -1 -> 1,1 so we now cover the whole screen and we're finished with step 1.
We've already seen that Field can draw lines and shapes using FLine
. Field can also associate event handlers with these shapes to make them interactive. This includes letting you drag them around — even in 3d. Full documentation is here (and an associated "deep dive" is here).
Here's what we need to make a draggable FLine
. First a handler:
class SomeHandler(Eventer):
def __init__(self, line):
self.on= line
def down(self, event):
self.downAt = Vector2(event.x, event.y)
return 1
def drag(self, event):
a, b = self.on.bounds()
center = (a+b)*0.5
# --- begin 3d math
projector = canvas.camera.getProjector()
p1 = projector.toPixel(center)
p1 += Vector3(event.x-self.downAt.x, -(event.y-self.downAt.y),0)
centerNext = projector.fromPixel(p1.x, p1.y, center)
# --- end 3d math
self.on+=centerNext-center
self.on.forceNew=1
self.downAt = Vector2(event.x, event.y)
The critical part here is the part marked '3d math'. Let's go through it line by line:
* projector = canvas.camera.getProjector()
— a projector
is a convenience class in Field to help you move between world space (the space that you specify geometry in) and pixel space (the space of screens, and, as it happens, frame buffer objects.
* projector.toPixel(center)
— this gives us the pixel location of where the center of the FLine
appears
* p1 += Vector3( ... )
— this moves the center by the amount that the mouse has moved in screen space — watch for that minus sign on the 'y' axis (see the explanation above).
* projector.fromPixel( ... )
— this converts back again from pixels back out into world space (the space where geometry lives).
Why three parameters to fromPixel
because when we convert from world (3d) space to pixel (2d) space we lose information. A pixel actual represents a whole infinite ray poking out of the screen at that point. When we convert back from pixels where along that ray do we want? The 3rd parameter rids us of this ambiguity — we want the point on this ray that's closest to where the FLine
used to be.
Now let's draw our UI:
dragShader = makeShaderFromElement(_self)
canvas << dragShader
lines = dragShader.getOnCanvasLines(canvas)
lines.submit.clear()
vertexPositions = {}
numDivisions = 5
w = 0.1
for x in range(0, numDivisions):
for y in range(0, numDivisions):
ll = FLine().rect(x-w/2, y-w/2, w, w)
ll.containsDepth=1
ll.eventHandler = SomeHandler(ll) # --- adds our event handler
lines.submit.add(ll)
vertexPositions[x,y] = ll
A quick check will prove that these lines are in fact draggable.
One last move remains — connect these lines with the mesh itself. Let's put this in its own box:
from __future__ import division
def up():
def center(line):
a,b = line.bounds()
c = (a+b)*0.5
ndc= canvas.camera.getProjector().toPixelNDC(c)
return ndc
with mesh:
for x in range(0, numDivisions):
for y in range(0, numDivisions):
a = mesh ** center(vertexPositions[x,y])
mesh.setAux(a, 5, x/numDivisions, y/numDivisions)
for x in range(0, numDivisions-1):
for y in range(0, numDivisions-1):
mesh ** [x*numDivisions+y, (x+1)*numDivisions+y, (x+1)*numDivisions+(y+1),x*numDivisions+(y+1)]
_r = up
We've wrapped this update code in its own function and written _r = up
. This tells Field that we can "run" this box. When we do so (say, by option-clicking on the box, or command-page up) we connect the UI to the warp mesh and we can drag the mesh around in real time. The following code, also in its own box, will give the FBO its own 3d animated camera and remove all doubt that the contents inside is in 3d:
camera = BasicCamera()
fbo.getSceneList() << camera
camera.width=canvas.width()
camera.height=canvas.height()
camera.aspect=canvas.width()/(canvas.height()+0.0)
def animateCamera():
s = camera.getState()
s.position = Quaternion(s.up, 0.01).rotateVector(s.position-s.target)+s.target
camera.setState(s)
_r = animateCamera
genMipmaps=1
to your call to makeFrameBuffer
.