Miru provides camera, lighting, render state grouping and various other abstractions which facilitate the task of rendering objects in an OpenGL context. It is tightly integrated with first pyglet (more specifically pyglet.window, pyglet.gl and pyglet.font modules), which provides a portable (Linux, Mac OSX, and Windows) and raw interface to the OpenGL GL and GLU libraries via ctypes, secondly zope.interface which provides the building blocks for a component architecture that make several features of the library easy to implement, and lastly Twisted, which, via twisted.python.components, provides a component registry on top of zope.interface.
Miru might be useful if you are daunted by OpenGL and want to write a 3D application. I am almost 68.73% confident that there is a 2 out of 3 chance that Miru will help you in a small (but core) subset of relevant tasks without requiring more than a beginner's understading of OpenGL.
NB 1:: Miru is pre-alpha. I will make no promises on its stability. Use at your own risk. If you find any bugs, I'd like to know - and patches are ideal of course if it is a real bug versus a feature you haven't appreciated yet. You can email me directly or communicate over the pyglet mailing list - Miru is essentially a pyglet application.
NB 2: Miru is implemented in the Python programming language and is intended for use in Python-based applications.
You can grab the current distribution of Miru here:
Miru 0.0.2 http://miru.enterthefoo.com/releases/Miru-0.0.2.tar.gz
You can grab the latest sources from the mercurial repository as well:
hg clone http://hg.enterthefoo.com/Miru Miru
There is also a dependency-bundled version of Miru (including Twisted, zope.interface and pyeuclid) which will allow you to play around with Miru to see the gruel I've generated and decide for yourself if it's even worth bothering with before properly installing the dependencies:
Miru 0.0.2 (w/ deps) http://miru.enterthefoo.com/releases/Miru-bundled-0.0.2.tar.gz
If you want to install globally to your python installation, do the usual thing:
$ python setup.py install
A side note: Anything "C" in either Twisted or zope.interface is strictly an optional C optimization - and trust me, you don't really need these C optimizations for Miru. Distutils and setuptools may trick you into beliveing that you have to build zope.interface and Twisted This simply isn't true. [1]
The following demonstrates a minimal miru application:
from miru.ui import TestWindow
from miru.environment import env
w = TestWindow(680,400)
env.window = w
while True:
w.clear()
w.dispatch_events()
env.render()
w.flip()
Firing up this example you will see ... a black screen! Congratulations! Okay, so that isn't very much fun. Let's make a window with a mesh loaded from an external .OBJ file by adding the following lines to the above program:
from miru.mesh import loadObj
import os
o = loadObj(os.path.join('docs', 'demo', 'alien.obj'))
env.addobj(o)
And finally we can also turn on our interactive python interpretter using the toggle_console function from miru.editor:
from miru.editor import toggle_console
toggle_console()
TestWindow also will give you a default key-binding CTL-T (which will allow to turn the on-screen display console on and off). In most applications, you should create your own window implementation (which generally should be a subclass or miru.ui.BaseWindow) with your desired global key bindings. For example:
class MyWindow(miru.ui.BaseWindow):
def on_key_press(self, key, modifiers):
...
Note that the interactive interpretter is great tool for learning Miru and pyglet. It allows you to directly manipulate the environment and see the effects.
Finally, here is a completed example - "Hello World":
from miru.ui import TestWindow
from miru.environment import env
from miru.mesh import loadObj
from miru.editor import toggle_console
import os
w = TestWindow(680,400)
env.window = w
# Load mesh from external Wavefront OBJ file
o = loadObj(os.path.join('docs', 'demo', 'alien.obj'))
env.addobj(o)
# Turn the console on
toggle_console()
while True:
w.clear()
w.dispatch_events()
env.render()
w.flip()
A scene rendered with a "usable" Python console in the display
Remember CTL-T can be used to turn the console on and off. Also, a mouse handler is enabled by default which will enable you to click on the trapezoidal object and move it around. Gee, that was ... fun.
This section will give an overview of how to correctly use components provided by miru to build 3D applications.
The module miru.imiru contains all the interfaces used by miru. An explanation of what interfaces do is outside the scope of this document, but briefly, an interface documents the expected behavior of a component separately from the implementation. Since interfaces are actually code, they can also be used to verify implementation correctness in unit tests, and adapt arbiraty objects to an interface at runtime. Many classes in miru implement interfaces imported from miru.imiru, and in those instances you should peruse the documentation - if it exists ;) - on the interface as opposed to the concrete implementation. For example, miru.environment.Environment implements imiru.IEnvironment:
class Environment(object):
implements(IEnvironment)
Behold, the singleton anti-pattern! --Also Sproch Zarahustra
The environment class provides a global singleton which can be used to reference many objects that often need to be queried on a global level. Exposed attributes are documented in IEnvironment and include:
The env object also provides a simple interface for adding and removing objects. To add an object:
from miru.environment import env
env.addobj(obj)
To remove and object:
env.delobj(obj)
An instance of miru.mesh.Object is simply a positional placeholder for an drawable object. To newObject function should be used when creating and object to be added to the global environment. Example:
from miru.mesh import newObject, Sphere
sphere = newObject(Sphere, radius=0.5)
env.addobj(sphere)
newObject takes a class followed by positional and keyword arguments to pass to the __init__ method of the class. It returns an instance of Object wrapping the underlying drawable object.
Note
There are many reasons for using a wrapper rather than the underyling object directly with postional attributes inherited from a super class. For one, we can have serveral distinct objects with different positions and orientations reference a single drawable object. Most importantly, a drawable object may consume a fair amount of space with vertex and normal data, so having a container to reference it provides a convenient memory optimization.
To move the sphere we created along the y-axis by Y:
sphere.pos += (0,Y,0)
To rotate the sphere along it's Z-axis by Z:
sphere.angle += (0,0,Z)
Note
The orientation of an object is indeed just an Euler. This is problematic for advanced transformations and interpolation of poses, so Miru will likely expose more flexible matrix or quaternion representations in a future version. For now, you'll have to suffer the badness of the Euler representation or write your own transformation functions on top of Miru.
To remove the sphere from the environment:
env.delobj(sphere)
Instances of Object have an attribute drawable which provides a reference to the underlying drawing primitive (several of which, including Mesh) are defined in the mesh module. The loadObj function referred to in the Quick Start section merely wraps a new Mesh in an instance of Object:
def loadObj(filename, flip_normals=False, interleave=True):
from miru.tools import obj
mesh = _loaded_meshes.get(filename)
if mesh is not None:
return Object(mesh)
parser = obj.ObjParser(filename, flip_normals=flip_normals)
f,d,data = parser.parse()
mesh = obj2mesh(f, data, interleave=interleave)
_loaded_meshes[filename] = mesh
return Object(mesh)
Let's examine an Object instance to see how it wraps the underlying primitive:
>>> o = newObject(Sphere, radius=2)
>>> o
<miru.mesh.Object at 89d254c <miru.mesh.Sphere object at 0xb789da6c>>
>>> o.drawable
<miru.mesh.Sphere object at 0xb789da6c>
>>> from miru.imiru import IWorldObject # Adapting to IWorldObject provider
>>> IWorldObject(o) == o.drawable
True
The IWorldObject interface is simply a stub interface for marking objects as appropriate for rendering in a 3D context. Implementations include miru.mesh.Sphere, miru.mesh.Mesh and miru.mesh.ImageWrapper.
Objects can also be grouped together by via an instance of miru.mesh.Group. The initializer for a group takes a list of objects:
from miru.mesh import Group, Sphere, newObject
s1 = newObject(Sphere)
s2 = newObject(Sphere)
s2.pos = (1,0,0)
s3 = newObject(Sphere)
s3.pos = (2,0,0)
group = Group(s1,s2,s3)
env.addobj(group)
Note that Group is a sublclass of Object. A group can also be compiled, meaning the underlying objects are drawn once as part of a display list:
group = Group(s1,s2,s3,compile=True)
env.addobj(group)
Be careful when using the compile flag, since one or more of grouped objects' draw method may invoke call a display list itself via glCallList - and nesting display lists is an invalid operation. For grouping Mesh instances (which by default use a display list on draw), you can first create the meshes with compilation turned off. For example:
head = loadObj('head.obj', compile=False)
hat = loadObj('hat.obj', compile=False)
group = Group(head, hat, compile=True)
env.addobj(group)
Caution!
You should never compile a group if the objects making up the group change position relative to the root position at runtime. In other words, those objects will not get redrawn, of course, to reflect their new positions.
Camera and Lighting abstractions can be found in the module miru.camera. Creating a camera:
from miru.camera import Camera
camera = Camera(pos=(1,4,5), wireframe=True)
env.cameras['secondary'] = camera
The above code creates a camera positoned at XYZ coordinates (1,4,5) and with wireframe rendering turned on. To set the main camera to the secondary camera we defined above:
camera.objects = env.camera.objects # link visible objects between cameras
env.camera = env.cameras['secondary']
We won't be able to see anything in our scene, however, since we haven't declared any lights for our camera. Let's fix this:
from miru.camera import LightGroup, PositionalLight
lights = LightGroup([PositionalLight(), PositionalLight((2,3,4))])
env.camera.lights = lights
An interesting feature of miru.camera.Camera is the ability to follow objects - similar to stationary camera on a rotational pivot.
raceCar = loadObj('racecar.obj')
env.camera.track_target = raceCar
To disable, simply set track_target to None:
env.camera.track_target = None
The camera's angle can also be interpretted as either a rotation (or orbit) around the origin, or a rotation around the local axis. Support for orbiting around a specified point is a planned feature.
To set the rotational mode of the camera to orbit (default):
env.camera.rotation_mode = Camera.ORBIT_MODE
To set the rotational mode of the camera to local axis based:
env.camera.rotation_mode = Camera.ROTATION_MODE
The are two classes of lights currently supported by Miru:
A directional light acts as a light placed at an inifite distance from the visible object, providing ambient light with a constant from it's origin. A positional light behaves similar to a spot light.
Debugging enabled on positional lights placed in a scene
A positional light with a spot cutoff in the range [0,90] will have a cone-shaped light with an angle given by the cutoff value:
spotlight = camera.PositionalLight(pos=(0,2,0), spot_cutoff=25,
spot_exponent=10, kq=0.1)
env.camera.lights.append(spotlight)
You can enable debugging on a positional light to see it's location in the scene.
>>> pl = env.camera.lights[0] # assuming the first light is a positional light
>>> pl.debug = 1
By default, a camera has a perspective projection - objects in the distance appear smaller than objects closer to the camera's position. The projection can be set on on a camera with either a perspective projection (miru.camera.PerspectiveProjection) or an orthographic projection (miru.camera.OrthographicProjection).
from miru import camera
orthoproj = camera.OrthographicProjection()
env.camera.projection = orthoproj
Position and orientation tracks be defined between objects that implement miru.imiru.IPostional [2] .
So what is track? A track defines master-slave relationship between two objects on either the objects position or attribute. In Miru's terminology the master is called to as the tracked and the slave is called the tracker. When a track is defined over a tracker S and a tracked T the position or angle of S is always changed relative to T. Thus, if a positional track is defined between S and T where S began at (x,y,z) and T began at (u,v,w), if S moves to position (x2,y2,z2), then T is implicitly translated to position (u + (x2-x), v + (y2-y), w + (z2-z)). Confused? Good let's move on to the next section.
Here is a concrete example of this:
from miru.track import PosTrack
leg = loadObj('leg.obj')
foot = loadObj('foot.obj')
foot.pos -= (0,2,0)
track = PosTrack(foot, leg) # Set up the Positional Track
Notice now that as the leg moves, the foot moves along with it:
>>> tuple(leg.pos)
(0, 0, 0)
>>> tuple(foot.pos)
(0, -2, 0)
>>> leg.pos += (1,2,3)
>>> tuple(leg.pos)
(1, 2, 3)
>>> tuple(foot.pos)
(1, 0, 3)
The tracker-tracked relationship is also one-to-many, so we can create other tracks on the leg:
knee = loadObj('knee.obj')
knee.pos = leg.pos - (0,1,0)
track2 = PosTrack(knee, leg)
Tracks, can also be used to make a camera follow an object in the world, which can be very usefule for games. You can define a PosTrack as defined above or even use a custom side-scroller track which will only follow the tracked object as it reaches the screen boundary:
from miru.track import SSCameraTrack
avatar = loadObj('avatar.obj')
SSCameraTrack(env.camera, avatar, env.window)
You can also compose tracks. With a slight modification to the above code example we can make our camera also follow the avatar along the z-axis:
from miru.track import SSCameraTrack
avatar = loadObj('avatar.obj')
SSCameraTrack(env.camera, avatar, env.window)
PosTrack(env.camera, avatar, axes=(0,0,1))
The camera allows the ball to move freely in the scene and then follow it as it reaches the edge of the viewport.
Caution!
Do not be confused by the track_target attribute of miru.camera.Camera. While similar in concept (and in terminology) - track_target does not use a Track. Expect wacky things to occur (for now), if you set track_target on a camera and also set up tracks as described in this section.
Tracks are registered globally on both the tracker and the tracked. To get a list of tracks setup for Avatar as a tracked object:
>>> from miru.track import gettracks
>>> gettracks(tracked=avatar)
[...]
We can call also find out what we're tracking:
>>> gettracks(tracker=env.camera)
[...]
Tracks can also be deactivated (or disabled). To disable a single track:
>>> track.deactivate()
>>> leg.pos += (123,45,67)
>>> foot.pos
(1,0,3)
Now our foot no longer follows the leg. We can also disable all tracks on a tracked object:
>>> from miru.track import deactivate
>>> deactivate(tracked=avatar)
>>> gettracks(tracked=avatar)
[]
Likewise, we an deactivate all tracks with a given tracker.
>>> deactivate(tracker=env.camera)
>>> gettracks(tracker=env.camera)
[]
Caution!
Don't try to go overboard with tracks. If you define more than 10 trackers an object, you should start to worry. Tracking is a granular operation that isn't by any means optimized - it currently entails at least N function calls for N trackers.
miru.osd provides an OSD class will renders (by default) after objects in the 3D world are rendered. Objects which need to be rendered on screen and not affected by world transformations or lighting can be added to the OSD. For example, we can take an FPS display provided by pyglet and place this in the OSD:
from pyglet import clock
from pyglet import font
clock_display = clock.ClockDisplay(font=font.load('',18), color=(0.,1.,0.,0.5))
env.osd.addobj(clock_display)
The Python interactive console mentioned above is also OSD for display.
miru.effects provides two simple effects which can be added to a scene:
Effects are enabled by added them to the effects list on a Camera instance. To enable reflection over a plane:
from miru.mesh import CheckerBoard
from miru import effects
cb = newObject(CheckerBoard, even_color=(0.1,0.0,0.0,0.8), odd_color=(0,0.05,0,0.8))
refl = effects.Reflection(ground=cb, camera=env.camera)
env.camera.effects.append(refl)
Effects can also be combined. The following example demostrates a Reflection effect following by Fog application:
fog = effects.Fog(density=0.075, color=(0.65,0.9,0.75,0.5), equation=effects.Fog.EQ_EXP)
cb = newObject(CheckerBoard, even_color=(0.1,0.0,0.0,0.8), odd_color=(0,0.05,0,0.8))
refl = effects.Reflection(ground=cb, camera=env.camera)
env.camera.effects.extend([refl, fog])
Reflection and Fog are added to a scene to create the effect of objects reflecting over a still body of water.
In the introduction we mentioned that Miru supports render state grouping but we haven't explained yet what that actually means. We've also deferred explanation of what occurs in the render method on an environment. Hopefully this section will explain to you what render stages are, and why they're important in any 3D application.
OpenGL is a state machine, and while it can operate very quickly with modern graphics hardware, tacking on too much state in single render cycle can still be detrimental to performance. A necessary optimization is to sort objects drawn in a scene by their state based on enabled attributes: depth testing, lighting, etc. Enabling and disabling such attributes per object rendered is unacceptable.
The interface miru.imiru.IRenderStage specifies a grouping of objects which should all be rendered in the same driver state. For example, when drawing 3D meshes in a scene, certain key attributes should be applied for each object:
On the other hand, we might want to draw debugging objects as well in the 3D scene with an entirely different set of required attributes. Mixing such objects together with the former in an arbitrary order means we'll have to enable/disable attributes many more times than we need to - that is, optimially once per attribute per frame.
By default, the Environment object is initialized with 4 rendering stages in the following order:
All four render stages together in harmony.
You are able, however, to set the order as you please, as well as declare (via an interface) and implement new rendering stages to plug into the pipeline. Or, you can override previously declared interfaces with a new implementation. First, let's see how to reorder the render stage pipeline:
>>> from miru.imiru import *
>>> from miru.environment import env
>>> env.setRenderStages(IOSDRenderStage, IWorldRenderStage, IDebuggingRenderStage, IBlittableRenderStage)
Now the OSD appears behind visible objects in the scene!
You can also change the implementation of a render stage by overriding the registered implementation and resetting the render stages on the environment:
>>> from thirdparty import OSD2
>>> osd2 = OSD2()
>>> from miru.components import registerUtility
>>> registerUtility(IOSDRenderStage, osd2, override=True)
>>> env.osd = osd2
>>> env.setRenderStages(IWorldRenderStage, IOSDRenderStage) # Reset with only 2 render stages
We can also query the component providing a render stage on the environment:
>>> env.renderStage(IWorldRenderStage)
<miru.camera.Camera object at 0x87b982c>
Declaring a render stage is also simple - just create a new interface:
from miru.imiru import IRenderStage
from zope.interface import implements
class IFragmentShaderStage(IRenderStage):
"""Render stage for GLSL fragment shader
"""
class FragmentShaderStage:
implements(IFragmentShaderStage)
def __init__(self):
self.objects = []
def render(self):
pass # implement me ;)
def addobj(self, obj):
self.objects.append(obj)
def delobj(self, obj):
self.objects.remove(obj)
Now we'll register the new render stage and set the render stages on our environment as desired:
>>> from miru.components import registerUtility
>>> registerUtility(IFragmentShaderStage, FragmentShaderStage())
>>> env.setRenderStages(IWorldRenderStage, IFragmentShaderStage, IOSDRenderStage)
Drawable objects in miru provide an attribure renderStages which gives a list of render stage interfaces to which the object belongs. This is generally defined first as a class attribute giving new object instances a sensible default. miru.mesh.Mesh, for example, defines renderStages as (imiru.IWorldRenderStage,). There is a simple way to override this if you wish to place an object in a non-default render stage:
from pyglet import image
from miru.mesh import ImageWrapper
from miru.environment import env
img = image.load('poster.png')
imgw = newObject(ImageWrapper, img).inRenderStages(IWorldRenderStage)
env.addobj(imgw)
Instances of ImageWrapper generally belong to the IBlittableRenderStage which disable depth testing and lighting. In the above example we've used the inRenderStages method on Object to declare that the underlying ImageWrapper instance should be drawn in the IWorldRenderStage along with other lit objects.
| [1] | There is an old school - but still very legitimate - technique you can exploit when faced with dependencies with inflexible setup.py scripts - copy the various libraries to your PYTHONPATH or site-packages directory. |
| [2] | Actually, I lied, the nitty gritty mechanics of tracks assume boldly that the objects inherit from miru.common.PositionalMixin. |