Information in this document is subject to change without notice. The example companies, organizations, products, people, and events depicted herein are fictitious. No association with any real company, organization, product, person or event is intended or should be inferred. Complying with all applicable copyright laws is the responsibility of the user. Without limiting the rights under copyright, no part of this document may be reproduced, stored in or introduced into a retrieval system, or transmitted in any form or by any means (electronic, mechanical, photocopying, recording, or otherwise), or for any purpose, without the express written permission of Microsoft Corporation.
Microsoft may have patents, patent applications, trademarked, copyrights, or other intellectual property rights covering subject matter in this document. Except as expressly provided in any written license agreement from Microsoft, the furnishing of this document does not give you any license to these patents, trademarks, copyrights, or other intellectual property.
© Microsoft Corporation. All rights reserved.
Microsoft, MS-DOS, MS, Windows, Windows NT, MSDN, Active Directory, BizTalk, SQL Server, SharePoint, Outlook, PowerPoint, FrontPage, Visual Basic, Visual C++, Visual J++, Visual InterDev, Visual SourceSafe, Visual C#, Visual J#, and Visual Studio are either registered trademarks or trademarks of Microsoft Corporation in the U.S.A. and/or other countries.
Other product and company names herein may be the trademarks of their respective owners.
This source code is subject to terms and conditions of the Apache License, Version 2.0. A copy of the license can be found in the License.html file at the root of this distribution. If you cannot locate the Apache License, Version 2.0, please send an email to ironpy@microsoft.com. By using this source code in any fashion, you are agreeing to be bound by the terms of the Apache License, Version 2.0.
3D Graphics in IronPython
A Flexible DirectX Framework in IronPython
By Lee Culver
Loading Assemblies and Importing Them
Your First DirectX Application (in IronPython)
An Introduction to Duck Typing
Cameras, Moving Objects, and Events
Rotating Objects on the Screen
Cool Things to Do with the Project
Moving Objects Around, Part II
Moving Objects Around, Part III
Gravity and Free Floating Bodies
In this tutorial we will be creating a highly flexible Python graphical framework as an introduction to IronPython. By the end of this tutorial you will have a better understanding of IronPython, a basic grasp of DirectX, and a better understanding of how IronPython utilizes and interacts with the .NET framework.
This tutorial assumes only a basic knowledge of the Python syntax and how to use some of its basic constructs (such as lists and tuples). No prior knowledge of DirectX or Windows.Forms is required, though a basic grasp of the .Net platform will go a long way to understanding a few sections of code.
We will be building this application from scratch, and all portions of the code will be fully explained. Note that if you are already familiar with Python, you should probably skip the sections labeled “For Python Beginners.”
Further, you will need the following:
Important Note
If you've previously installed a DirectX SDK there's a good chance you might have the Managed DirectX 2.0 Beta assembly installed. This assembly is incompatible with the Python sample code distributed with this tutorial and cannot be uninstalled. If you experience difficulties running the samples (particularly when there are error messages stating that the Matrix class has no LookAtLH method) and are sure you meet the prerequisites above, please replace all occurrences of:
clr.AddReference(“Microsoft.DirectX”)
with:
clr.AddReferenceByName('Microsoft.DirectX, Version=1.0.2902.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35')
throughout the samples. This change basically means that the sample code will use Managed DirectX 1 instead of the 2.0 beta which would normally have precedence.
This tutorial will start from a small set of code and slowly build a full framework from scratch. Throughout this tutorial you should be slowly adding code to a Python source file of your own and watching the results as we build it. There is no substitute for actual programming; I highly recommend that you resist the urge to simply read along.
By the end of the first part of this tutorial we will build a framework which will allow you to import any number of DirectX meshes and view them inside of a .NET form. The framework will provide methods for positioning and rotating objects and the camera, as well as examples of using the framework to implement more advanced functionality such as having objects move around a track and have cameras auto-track objects in the scene. Lastly, we will implement a small scale demo which demonstrates Newton’s law of universal gravitation, which will allow you to specify an arbitrary number of free floating bodies in space (planets), and watch them interact with each other.
Throughout this tutorial be very careful to keep the same indentation levels that have been used here. In Python, white space is a part of the language, and you must take care to ensure that everything is correctly aligned or you will receive errors. I suggest setting your text editor of choice to use 4 spaces instead of tabs (which I have done for all code presented here).
Lastly, if you are getting compile errors, check to make sure you have not inserted extra returns in the middle of statements. The code may be slightly skewed if you have word wrap turned on.
The first thing we will do is add a few references to assemblies we will be using. Create a new file called “tutorial.py” and add the following code to it:
import clr
clr.AddReferenceByPartialName("System.Drawing")
clr.AddReferenceByPartialName("System.Windows.Forms")
clr.AddReferenceByPartialName("Microsoft.DirectX")
clr.AddReferenceByPartialName("Microsoft.DirectX.Direct3D")
clr.AddReferenceByPartialName("Microsoft.DirectX.Direct3DX")
If you do not add the reference to assemblies that you use, the IronPython interpreter will not know how to load them. If you run into problems where an import statement does not give the expected results, check to make sure you have referenced all of the appropriate assemblies first. Note that IronPython automatically adds the “System” library.
If you encounter difficulties running the snippet of code above, please see the Important Notes section in the Prerequisites.
|
New to IronPython? The clr import is not just a library; it actually changes the dynamics of several basic constructs. To see this first hand, open up a fresh IronPython interpreter and run this code:
print dir([]) import clr print dir([])
Notice that it adds a number of functions which makes IronPython very similar to .NET (though all of the Python functions are still there). This also replaces the standard Python string with the .Net string class. Throughout this tutorial I will be using the IronPython methods instead of the standard Python methods as this produces a more consistent code base. |
Next we will import all of the libraries we will need for this tutorial.
import System
from System import Drawing
from System.Windows import Forms
from Microsoft import DirectX
from Microsoft.DirectX import Direct3D
In this tutorial I have kept namespaces and never imported everything into the global namespace. This will make the rest of the tutorial slightly more verbose, but it will also be very explicit as to which functions we are calling.
The next thing we need to do is to create a new Form to house our application. In IronPython we can natively derive from any .Net class which is not sealed or a value type. We will define a constructor which takes in a single argument (a class which contains a “Paused” attribute). Though I am calling this variable sceneManager here, and we will be providing a class called SceneManager later, we could easily swap it for any other class which understands how to pause and unpause the system:
class RenderWindow(Forms.Form):
def __init__(self, sceneManager):
self.SceneManager = sceneManager
self.Text = "IronPython Direct3D"
self.ClientSize = Drawing.Size(640, 480)
Here we have set our own instance variable SceneManager to be the sceneManager variable that was passed in. We have also set the “Text” and “ClientSize” properties, which are properties defined by the Forms.Form class.
|
New to Python? When defining a class’s method in Python, you always add a parameter for the object you are currently operating on. In languages such as C# or C++ this parameter is implicitly passed as the “this” parameter. Note that we cannot access a class’s member methods or data without using this self parameter. |
Next, we want our window to close (and the application to exit) whenever the escape key is pressed. We will override the OnKeyDown method of Forms.Form to do this.
def OnKeyDown(self, args):
if args.KeyCode == System.Windows.Forms.Keys.Escape:
self.Dispose()
Finally, we want the application to pause and stop rendering any time the application is minimized or not visible. For now we will set the Paused variable on the SceneManager variable we have created. We will actually implement the code for this later.
def OnResize(self, args):
self.SceneManager.Paused = not self.Visible or \
(self.WindowState == Forms.FormWindowState.Minimized)
Next we are going to add the shell of the SceneManager class. The first three methods are very simple (and we will revisit their implementations later). The first is the constructor, the second is a method which sets up DirectX, and the third is the Render method, which is called from within our render loop. We will cover these in detail later.
class SceneManager(object):
def __init__(self):
pass
def InitGraphics(self, handle):
return True
def Render(self):
pass
|
New to Python? Whenever we define a method or class which has no body, we always must use the pass keyword. This keyword has no meaning outside of saying “empty code block.”
Note that we have returned True from InitGraphics. One important thing to note about Python is that any time you do not return a value, you are implicitly returning the None type (which is roughly equivalent to null, with a few exceptions). This is why you will see some code paths in Python which have return statements and some which do not. This is perfectly valid, and generally accepted as normal. |
Next we need to create the render loop for the application. This loop will pump the Windows.Forms event queue and call the Render method:
def Go(self, window):
while window.Created:
self.Render()
Forms.Application.DoEvents()
Finally, we will create the main function which creates the basic objects and starts the render loop.
def main():
sceneManager = SceneManager()
window = RenderWindow(sceneManager)
if not sceneManager.InitGraphics(window.Handle):
Forms.MessageBox.Show("Could not init Direct3D.")
else:
window.Show()
sceneManager.Go(window)
if __name__ == '__main__':
main()
|
New to Python? In a Python script it is ok to have code which runs at the file level (that is, not inside of a function, not inside of a class). This code is run every time the code is imported. If you want to have the code run only when the module is explicitly ran (not imported) we use the built in __name__ variable to determine if we have been explicitly invoked from the command line. It will always equal ‘__main__’ if we have been run from the command line. If the module was loaded using the “import” statement, __name__ would be the name of the file otherwise.
We have explicitly created a main function so that if this code is imported into an IronPython console, the main function can be invoked explicitly. If we replaced the call to main with the contents of the main function, there would be no way to run the code outside of actually running it from the command line (and not importing it). This is sometimes desirable, but not in our application. We will be using this much later in the tutorial when we actually import our framework from a separate file.
Notice also that creating a class does not use the “new” keyword like C# and C++ do. The syntax for creating a new class is the exact same as calling a function. This is by design. In many cases we can use function calls and class constructors interchangeably when we are passing functions as parameters. |
Run the code from within IronPython. You should see an empty Windows.Forms window. If you are having problems with the code, you should compare your source file with checkpoint1.py.
In this section we will create a basic scene in DirectX. By the end of this section, we will have created a DirectX context, rendered a mesh to the scene, and explored the Python concept of Duck Typing along the way.
This section will deal exclusively with the SceneManager class. The first thing we need to do is initialize a few variables which we will be using in this code. Be sure to always remove the “pass” statement when we add code to empty methods.
We will add three variables: The first will contain our DirectX device when we have created it. The second will determine if the DirectX context is paused (in case we are minimized or hidden). The third will be the background color for the DirectX rendering device.
class SceneManager(object):
def __init__(self):
self.Device = None
self.Paused = False
self.Background = System.Drawing.Color.Black
Next we will fill in the InitGraphics method. This section of code creates the DirectX device we will use in this tutorial.
The first thing we will do is set up the parameters that we will pass to DirectX. We will set DirectX to be in windowed mode, set the swap effect for drawing frames, and enable the auto depth stencil (so that DirectX tracks the depth of our objects for us). For more information on the specifics on these properties and parameters, consult the DirectX documentation. Since creating a DirectX device requires a window handle to place it in, we will take in a “handle” parameter to use this.
def InitGraphics(self, handle):
params = Direct3D.PresentParameters()
params.Windowed = True
params.SwapEffect = Direct3D.SwapEffect.Discard
params.EnableAutoDepthStencil = True
params.AutoDepthStencilFormat = Direct3D.DepthFormat.D16
Now we need to actually create the Direct3D device. Note that we are passing in the handle parameter to the constructor of the Direct3D device. This is what tells DirectX to use the window that we have created. We also pass in the PresentParameters object we just created. For more specifics on the Device creation parameters, consult the managed DirectX documentation.
self.Device = Direct3D.Device(0, Direct3D.DeviceType.Hardware, handle,
Direct3D.CreateFlags.SoftwareVertexProcessing,
params)
After creating the device, we need to set a couple of properties on the Device. The first thing we need to do is enable the ZBuffer. If we did not do this, we could not draw in 3 dimensions (which is basically the point of this tutorial). We also need to set the ambient light to be a full white. There are multiple types of lighting which DirectX uses: diffuse, ambient, specular and emissive. A full treatise on lighting would be far beyond the scope of this document. For this tutorial we are going to simply set the ambient light to full (white), and force every mesh we load to use ambient lighting. This, effectively, turns off lighting so we do not have to worry about it.
self.Device.RenderState.ZBufferEnable = True
self.Device.RenderState.Ambient = Drawing.Color.White
Next we need to set up two of the three matricies which DirectX uses to render the scene. The Projection matrix is the most complicated. It basically transforms the 3D matrix of objects into a 2 dimensional plane to display on the screen. We will not have to deal with this matrix after we have set it the first time.
The second matrix DirectX uses is the View matrix which defines where the camera is looking. DirectX has some very nice methods which allow us to deal with cameras in a very natural way. The Matrix.LookAtLH takes in three parameters. The first is the position of the camera. The second is what the camera looks at. The third is the “up” vector, which is the up direction for the camera. Setting the up vector incorrectly would result in the scene being rotated in strange ways.
self.Device.Transform.Projection = DirectX.Matrix.PerspectiveFovLH(System.Math.PI/4.0, 1, 1, 100)
self.Device.Transform.View = DirectX.Matrix.LookAtLH(DirectX.Vector3(0, 3, -5), DirectX.Vector3(0, 0, 0), DirectX.Vector3(0, 1, 0))
The rest of our code simply sets the Paused property to be false. We will return true to indicate no error occurred.
# ensure we are not paused
self.Paused = False
return True
The last thing we need to do is render the scene. For now this code is very basic, but we will be adding to it later. First, we only render if the Device has already been created and we are not paused.
def Render(self):
if self.Device is None or self.Paused:
return
At the beginning of each render, we clear the scene with a specific background color (which we set in the constructor).
self.Device.Clear(Direct3D.ClearFlags.Target | Direct3D.ClearFlags.ZBuffer,
self.Background,
1,
0)
self.Device.BeginScene()
# scene render here
self.Device.EndScene()
self.Device.Present()
Run the application. We have now created a DirectX render loop in under 100 lines of IronPython code. Now we need to make something useful out of it…
Run your Python application. At this point you should have the same window as the previous checkpoint, but with a black DirectX context. If you are having problems with the code, you should compare your source file with checkpoint2.py.
Now that we have an application running, what we need to do is load a mesh and render it in the scene. To do this, DirectX needs four pieces of information: the Mesh, the World Matrix (to position the object), a list of materials, and a list of textures. We will provide one extra constraint that we will use: every object we render in the scene will need a Name so that we can place it into a dictionary for easy access.
A common approach to solving this problem in strongly typed languages such as C++ and C# is to define an interface which classes must implement to be used by the SceneManager. This is a good approach for these languages, but it can be extraordinarily limiting when using a more flexible language such as IronPython. The concept of an interface still exists in IronPython, but instead of being an explicit interface, it is a more contractual negotiation between objects which interact with each other.
In this case, we need to implement an interface for our “Renderable” objects. The naïve approach (for python programming) would be to define an explicit class to handle this (this code should not be added to the project):
class IRenderable:
Name = ""
Mesh = None
Textures = []
Materials = []
def GetWorldMatrix(self):
pass
def function(renderable):
if not isinstance(renderable, IRenderable):
raise TypeError
# use the instance
This is certainly a valid solution, but completely unnecessary. Instead, the Python way is to simply define the required methods and attributes and only use inheritance when code is actually being shared between classes. This can lead to many interesting uses of a class which were not anticipated by the programmer who wrote the original code. The fact that we have not constrained the “Name” attribute to be a string type means we can use any hashable object (such as integers, strings, the None type, classes, instances of classes…) to access scene objects. This style of programming also has the obvious benefit of less coding overhead. We do not have to define the interface in the code, nor do we have to explicitly declare it, so long as we implement all of the correct functionality in an object.
A good example of this would be the RenderWindow class. We pass the scene manager into the RenderWindow class so that the window can pause the scene manager when it is not being shown. From the RenderWindow’s perspective, it doesn’t care what object it is given so long as that object understands what is meant when the programmer sets the Paused variable. This can be seen as an implicit interface, containing only the Paused property as the member of the interface. As briefly mentioned before, we could easily swap the object we pass to RenderWindow with a different one if we so choose (so long as the new object understood what to do with that Paused property), without ever having to change the RenderWindow class.
This is what is meant when Python programmers refer to “Duck Typing.” The idea is “if it walks like a duck, quacks like a duck, it might as well be a duck.” Or to use a less obtuse metaphor, if a class implements everything that is needed to perform a task, do we really care what class it actually is, or what interfaces it claims to implement?
Along with the general flexibility of the syntax and constructs of the language, Duck Typing is the primary reason that Python as a whole is so lightweight. A change to a method in one class only has to be reflected in the other classes which rely on this method.
With this in mind, we will now set out to implement our first iteration at
rendering meshes. The first thing we will do is create a new class which loads
a mesh and implements the interface we have outlined. The constructor for our
class will take in three items. The first is the DirectX device to use to load
it, the second is the name of the object, and the last is the .x file we are
loading.
class MeshRenderable(object):
def __init__(self, device, name, file):
self.Name = name
Now we need to load the actual mesh itself. The major parameters we are passing are the Direct3D device, the filename to load the mesh from, and an empty list of materials which DirectX will populate . Please consult the managed DirectX documentation for more information on the other parameters, if you are curious.
materials = clr.Reference[System.Array[Direct3D.ExtendedMaterial]](())
self.Mesh = Direct3D.Mesh.FromFile(file,
Direct3D.MeshFlags.SystemMemory,
device, materials)
materials = materials.Value
Now that we have loaded the mesh into self.Mesh and loaded a list of materials into the “materials” variable, we will build a list of preloaded Materials and Textures. As mentioned previously, we are going to use only the ambient lighting for this tutorial and we implement this by setting the ambient color to be the diffuse color on the mesh.
self.Materials = []
self.Textures = []
for i in range(materials.Length):
# load material, set color
material = materials[i].Material3D
material.AmbientColor = material.DiffuseColor
The material itself only provides us with the texture’s file name We actually have to load the texture ourselves.
texture = None
texFile = materials[i].TextureFilename
if texFile is not None and texFile.Length:
texture = Direct3D.TextureLoader.FromFile(device, texFile)
Next we add the material and the texture to their respective lists.
self.Materials.append(material)
self.Textures.append(texture)
Lastly we need to implement the GetWorldMatrix method. For now, we will simply return the identity matrix, because we have not implemented any positioning code.
def GetWorldMatrix(self):
return DirectX.Matrix.Identity
Now that we have code for loading a mesh, we need to add some code to actually render it. Along the way, we will want to collect the objects into a common place, and the SceneManager class seems like an obvious place to do this.
First, add a new dictionary to the SceneManager class called “Objects.”
class SceneManager(object):
def __init__(self):
self.Device = None
self.Paused = False
self.Background = System.Drawing.Color.Black
self.Objects = {}
To abstract away some of the details of loading a mesh we will provide a mechanism for loading textures. This method will take in the name of the object and the filename to load, store the object in the Objects dictionary, and return the Mesh (in case the caller actually needed to manipulate the object after creating it).
def LoadMesh(self, name, filename):
mesh = MeshRenderable(self.Device, name, filename)
self.Objects[mesh.Name] = mesh
return mesh
Now we need to actually render the Objects. Find the comment in the Render method which says “scene render here.” We will implement this by walking through all of the values in the Objects dictionary. For each object that has the “GetWorldMatrix” attribute, we will assume that it is a mesh and render it as such.
To accomplish this we will use Python’s generator expressions to iterate through the Objects. To determine which objects have the “GetWorldMatrix” method we will use Python’s hasattr function. The hasattr function returns true if the first parameter passed into it contains an attribute which is named the second parameter. In Python, using reflection is as natural as breathing, and learning to use it effectively is the key to writing clean and concise code.
Between the self.Device.BeginScene() and self.Device.EndScene() calls, add the following code:
for mesh in (x for x in self.Objects.Values if hasattr(x, "GetWorldMatrix")):
There is one catch, we have only determined that the GetWorldMatrix exists, not if it is a callable method. Python implements a handy method for determining if an attribute is callable:
if callable(mesh.GetWorldMatrix):
Note we could have added the callable if statement to the end of the generator expression by adding the “and” boolean operator. As an exercise, make this change now.
The process for rendering a mesh is to specify the world matrix, then for each subset of a mesh, set the material and texture then render the subset. We can actually share mesh data between multiple objects by simply using the same Direct3D.Mesh objects and setting the world matrix for each render. For now, we will simply render the mesh without this optimization.
self.Device.Transform.World = mesh.GetWorldMatrix()
materials = mesh.Materials
textures = mesh.Textures
for i in range(len(materials)):
self.Device.Material = materials[i]
if i < len(textures):
self.Device.SetTexture(0, textures[i])
else:
self.Device.SetTexture(0, None)
mesh.Mesh.DrawSubset(i)
Note here that we have only set the texture if there is actually a texture for this particular material. There must be a material for the subset we are drawing, but there does not have to be a texture for that particular material.
Save your work and run the file. For all of our new changes, we don’t actually have anything new to show for it yet. Find the main function and add a call to the LoadMesh method as follows:
if not sceneManager.InitGraphics(window.Handle):
Forms.MessageBox.Show("Could not init Direct3D.")
else:
sceneManager.LoadMesh("tiger", "tiger.x")
window.Show()
sceneManager.Go(window)
Now run the application. You should see a tiger facing the camera. The next
thing we will do is implement a flexible event system to move the tiger.
Remove the call to LoadMesh from the main function, as that was only to
demonstrate that we are indeed loading the mesh.
In this section of the tutorial we will be exploring an event system which allows us to manipulate the scene by responding to events such as the scene being created or a frame being rendered. We will also add positioning code and rotational code, so you can arbitrarily move and rotate objects. Lastly, we will implement a camera system which allows us to set cameras throughout our scene and switch between them at will.
Though our event system will be able to handle any arbitrary events we create later, we will start with three basic events: OnSceneCreate, OnSceneBegin, and OnFrame. The OnFrame method will be called just before rendering a frame. This will allow us to reposition objects (or do other updates) which require action every frame. The OnSceneCreate method will be called before the scene starts rendering, and it is used for creating and adding objects (such as meshes and cameras) to the scene. The OnSceneBegin event will be fired right after the OnSceneCreate event has finished. The OnSceneBegin method is used to move and position objects which have not been created by that event handler. This is an important distinction since we are not guaranteeing anything about the order in which event handlers of the same type are called. By offering these two events separately, we can allow a single listener to create an object that another object uses without worrying about the order in which events are passed.
The OnSceneCreate and OnSceneBegin events will accept a single parameter (the scene manager who is calling them), and the OnFrame events will accept a parameter for the number of seconds since the last frame was rendered. Lastly, events return values indicating whether or not they have completed their operations. By returning True an object signifies that it should no longer receive any events.
To implement this, we will start by creating a dictionary which holds the event name as a key, and a list of event handlers as the value. Find the SceneManager constructor and add this code:
self.Listeners = {"OnFrame" : [],
"OnSceneBegin" : [],
"OnSceneCreate" : []}
We will add two methods for adding listeners to the class. The first is the AddListenerByName method, which takes in an event listener and a name of the event. The implementation is very straight-forward (raising a TypeError if we are given an event listener which is not callable):
def AddListenerByName(self, key, function):
if not callable(function):
raise TypeError, "Object not callable."
self.Listeners[key].Add(function)
This is good to have, but this would quickly become tedious to use if we had a large number of objects, each registering for multiple events. What we would like to do is to inspect an object, and register their event handlers based on the function names.
This is extraordinarily easy to do in Python. We will walk through each of the keys in the Listeners dictionary, and if the object has a function by that name, we will register it as an event listener:
def AddListener(self, obj):
for key in self.Listeners.Keys:
if hasattr(obj, str(key)):
function = GetAttr(obj, str(key))
self.AddListenerByName(key, function)
Only four lines of actual code to walk the object and pull out the functions we are looking for.
Now we need to add a method for actually firing the events. As I mentioned earlier, each event will return whether or not it has completed its operations. In practice this means that if we are complete we return True, if we are not complete (or if the listener never completes…if it should be registered for the entire lifetime of the SceneManager), then we can either return False, or simply not return anything at all. Since all methods implicitly return the None type, and since None always evaluates to false in a Boolean expression, this allows us to implicitly return false when we do not return at all. While difficult to explain the reasoning for this concisely, it will make our listener code easier to write.
To implement a method which fires events, then removes objects which return True, we will call every function in the appropriate listener list, keeping track of each function which returned true.
def __FireEvent(self, event, arg):
listeners = self.Listeners[event]
toRemove = []
for f in listeners:
if f(arg):
toRemove.Add(f)
Now we walk the toRemove list and remove them.
for f in toRemove:
listeners.Remove(f)
|
New to Python? Whenever you prefix a method or variable with two underscores, that method or variable is “hidden.” This is roughly equivalent to declaring it as “private” in a C++ or C#. The major difference is the variable or method is still actually there, it’s just difficult to modify unless you understand the internals of how Python hides it. This is implemented such that it is possible to have a “friend” class as you do in other languages (allowing you to access an object’s private data), but at the same time letting you know that you are doing something you really should try not to. |
Lastly, we need to actually call the __FireEvent method to send the events. To do this we will modify SceneManager’s Go method, so clear out the contents of it. We’ll start by firing the scene creation and scene begin events:
def Go(self, window):
self.__FireEvent("OnSceneCreate", self)
self.__FireEvent("OnSceneBegin", self)
Next we need to add code for firing the OnFrame event. The trick is we have to keep track of how long it has been since we last fired a frame event. We’ll use the System.Environment.TickCount (measured in milliseconds) to do this.
lastTick = System.Environment.TickCount
while window.Created:
currTick = System.Environment.TickCount
self.__FireEvent("OnFrame", (currTick-lastTick)/1000.0)
lastTick = currTick
self.Render()
Forms.Application.DoEvents()
Note that the extra “.0” in the __FireEvent call is very important. Both currTick and lastTick are integer values and currTick-lastTick is certainly less than 1000. If we did not divide it by a floating point number we would always be passing 0 (the integer division value).
We will now create a very simple event listener which adds our tiger mesh to the scene. We will be expanding this event listener in later sections.
class SceneCreator(object):
def OnSceneCreate(self, sm):
self.Tiger = sm.LoadMesh("Tiger", "tiger.x")
return True
Now, we need to register this listener. In general, we could have many listeners in our application. What we want to do now is to make the main function take in an optional parameter which defines a sequence of objects to register as listeners. Find the main function and change its function header:
def main(listeners = [SceneCreator]):
This creates an optional parameter “listeners” which, by default, contains our SceneCreator class.
As you see, we have passed in a class instead of an instance of the class. What we want is for our function to iterate over the sequence we are given, adding instances of the objects to the scene manager. If an entry in the sequence is a class, we want to create an instance of it and add it. Find the “else” clause in the main function, and add the following code:
else:
for obj in listeners:
if callable(obj):
obj = obj()
sceneManager.AddListener(obj)
window.Show()
sceneManager.Go(window)
Note that we have assumed here that a callable object is a constructor (or, at least, returns an instance of a listener object). This works “well enough” in most cases. The biggest problem we can run into is the fact that objects in Python can implement the __call__ method, which makes an object instance act like a function. If we added a class like this to the parameter list in main, we could run into trouble. There are ways around this, but I have left it out of this tutorial as this is a bit outside what we are exploring in Python. Feel free to look up Python’s documentation for more information related to classes.
Save your work and run the file. You should now see a tiger standing in front of the camera. If you are having trouble at this point, compare your code to checkpoint4.py.
Before moving on, I would like to note that we have skipped over a couple of useful methods which a full application would need. If you are new to Python, I suggest you implement these methods as an exercise. This is not required to move onto the next section, as we will not be making use of any of these.
First, implement a RemoveListenerByName method which takes in a name and a listener and unregisters that listener from the specified event.
Implement a RemoveListener method which unregisters a listener from all events.
The next thing we will implement is moving and rotating objects in the scene. To do this we will create two classes which will be subclassed by other scene objects to share the functionality.
The first thing we will implement is a PositionableObject class. This class will provide a Position property (containing a DirectX.Vector3 object), and a Translate method. The Position property will always contain a Vector3 instance that we can always directly pass into a DirectX function without having to convert it. To do this, we will add a function at the top level of the file which takes in a parameter and if the parameter is of sequence type, we’ll convert it to a Vector3. Be sure this function is added near the beginning of the file.
def Vectorize(v):
if type(v)==list:
v = DirectX.Vector3(v[0], v[1], v[2])
return v
Now we will implement the PositionableObject class using a Python property object for the Position variable. To define a property in Python, we first define a get method, a set method, a delete method, and a hidden variable to contain the value. (Note we will always call the Vectorize function before actually assigning to the __Position variable.)
class PositionableObject(object):
def __init__(self):
self.__Position = DirectX.Vector3(0, 0, 0)
def __GetPosition(self):
return self.__Position
def __SetPosition(self, pos):
self.__Position = Vectorize(pos)
def __DelPosition(self):
self.__Position = DirectX.Vector3(0, 0, 0)
To create the actual property itself, we call the property function:
Position = property(__GetPosition, __SetPosition, __DelPosition)
|
New to Python? The property function takes in 4 parameters. The first is the get method, the second is the set method, the third is the delete method, and the fourth is a help string. Only the first parameter (the get method) is required. If we do not specify a delete method, this will create a property which throws an AttributeError if the user tries to delete it. If we do not supply a set property then the variable will be read-only. The help string is also completely optional. |
Next, we will implement the Translate method. This is also straight forward, converting parameter then translating it.
def Translate(self, amount):
amount = Vectorize(amount)
self.__Position += amount
At this point you may wonder, “Why do we bother with a Translate method?” The problem we are faced with is that we are trying to support a “native” Python solution to this problem…that is, we would like our movable objects to work with lists in addition to DirectX.Vector3 objects. This works fine until we try to use them in conjunction with the Vector3 class (which has no idea how to deal with a python list). For example, this code works fine (do not add this to the project):
a = DirectX.Vector3(1, 2, 3)
b = DirectX.Vector3(3, 2, 1)
c = a + b
This, however, will fail (do not add this to the project):
a = DirectX.Vector3(1, 2, 3)
b = [3, 2, 1]
c = a + b
DirectX.Vector3 objects simply have no idea how to deal with lists (or any other object than Vector3s, for that matter). A common solution to this problem is to subclass the object and redefine its math operations such that it would convert from a list to a Vector3 automatically. This is unfortunately not possible since .Net does not support subclassing sealed or value types (and DirectX.Vector3 is a value type).
Our only option at this point is to add a Translate method which knows how to convert between sequence types and Vector3 objects, and add the result. For example (do not add this to the project):
# this will work
somePositionableObject.Position += DirectX.Vector3(1, 2, 3)
# this will not work
somePositionableObject.Position += 1, 2, 3
# this will work, which is why we created it
a = 1, 2, 3
somePositionableObject.Translate(a)
# this works too
a = DirectX.Vector3(1, 2, 3)
somePositionableObject.Translate(a)
The last thing we need to add is a read-only property called PositionMatrix, which (predictably) will return a matrix based on the position. We need this because all of DirectX’s positioning code relies on matrices and not vectors. To implement this we will use the DirectX.Matrix.Translation method to build a translation matrix based on the position vector, and then use this to create the PositionMatrix property:
def __GetPositionMatrix(self):
return DirectX.Matrix.Translation(self.Position)
PositionMatrix = property(__GetPositionMatrix)
The next thing we will do is add a class which handles rotating objects. The RotatableObject will act quite differently from PositionableObject in the sense that we do not have a single property we are interacting with. Instead we are going to implement four simple methods to perform rotations. The Pitch method will rotate the object around the x-axis, the Yaw method will rotate the object around the y-axis, the Roll method will rotate the object around the z-axis, and the ResetOrientation method will revert all orientation changes to the object.
class RotatableObject(object):
def __init__(self):
self.RotationMatrix = DirectX.Matrix.Identity
def ResetOrientation(self):
self.RotationMatrix = DirectX.Matrix.Identity
def Pitch(self, p):
self.RotationMatrix *= DirectX.Matrix.RotationX(p)
def Yaw(self, y):
self.RotationMatrix *= DirectX.Matrix.RotationY(y)
def Roll(self, r):
self.RotationMatrix *= DirectX.Matrix.RotationZ(r)
That is all that is required for adding object rotation.
The final thing we need to do before actually playing with our new code is have our MeshRenderable class actually implement these two base classes. Modify the MeshRenderable class to inherit from the PositionableObject and RotatableObjects, making sure to call the base constructors:
class MeshRenderable(PositionableObject, RotatableObject):
def __init__(self, device, name, file):
PositionableObject.__init__(self)
RotatableObject.__init__(self)
Next, change the GetWorldMatrix method to return the rotation matrix multiplied by the position matrix (remember that matrix multiplication is not commutative, and it matters greatly the order in which you multiply these two matrices).
def GetWorldMatrix(self):
return self.RotationMatrix * self.PositionMatrix
Now we can finally put this to use. We have already loaded a tiger mesh in our SceneCreator class. Now we will create a new class to position and animate the tiger. Have the tiger rotate around the y-axis. To do this, we’ll add a frame event which calls the Yaw method, passing in the elapsed time as the amount to rotate it.
class TigerAnimator(object):
def OnSceneBegin(self, sceneManager):
self.Tiger = sceneManager.Objects["Tiger"]
return True
def OnFrame(self, elapsed):
self.Tiger.Yaw(elapsed)
Finally, we need to add this listener to the SceneManager class. Since objects which affect a scene are parts of the scene too, it seems natural to have the SceneCreator class actually instantiate this object.
Update SceneCreator’s OnSceneCreate method to add this listener:
def OnSceneCreate(self, sm):
self.Tiger = sm.LoadMesh("Tiger", "tiger.x")
sm.AddListener(TigerAnimator())
return True
The last major scene object we will be adding to our framework is a Camera object. We will introduce a new variable in the SceneManager class called ActiveCamera to store the camera actually being used to view the scene. We will store inactive cameras in SceneManager’s “Objects” dictionary so that they may be accessed by any object which has a reference to the scene manager.
Our camera class will be a bit different from our object positioning and rotation code. Instead of rotating the camera around various axes to face the objects we wish to look at, we will instead implement a camera class which always “looks” at a given position. Nine times out of ten, this is what we wanted to do with a camera anyway (have it look at an object). In a full framework we would need to add a camera which did a bit more than look at a given position, but this will work for now.
class Camera(object):
def __init__(self, name):
self.Name = name
self.__LookAtVector = DirectX.Vector3(0, 0, -1)
self.__Position = DirectX.Vector3(0, 0, 0)
The next bit of code calculates a matrix based on the position of the camera and the position we want the camera to look at. It creates two properties (one for Position, one for LookAtVector), and updates the matrix every time either of these are changed.
def __UpdateMatrix(self):
self.Matrix = DirectX.Matrix.LookAtLH(self.__Position, self.__LookAtVector, DirectX.Vector3(0, 1, 0))
def __GetLookAtVector(self):
return self.__LookAtVector
def __SetLookAtVector(self, v):
v = Vectorize(v)
self.__LookAtVector = v
self.__UpdateMatrix()
def __GetPosition(self):
return self.__Position
def __SetPosition(self, pos):
pos = Vectorize(pos)
self.__Position = pos
self.__UpdateMatrix()
Position = property(__GetPosition, __SetPosition)
LookAtVector = property(__GetLookAtVector, __SetLookAtVector)
def LookAt(self, loc):
self.LookAtVector = loc
Finally, we will return the matrix using the GetViewMatrix method.
def GetViewMatrix(self):
return self.Matrix
Now we need to update the SceneManager class to use the new camera class. Find the SceneManager class’s constructor and add an ActiveCamera variable:
self.ActiveCamera = None
Now we will add a CreateCamera method to SceneManager which creates a camera of the given name and adds it to the Objects dictionary, returning the result. We will have an added constraint that if no camera has been set to be active, we will set the newly created camera to be the active camera. This lets us ignore setting the ActiveCamera variable if the application only uses one camera.
def CreateCamera(self, name):
cam = Camera(name)
self.Objects[cam.Name] = cam
if self.ActiveCamera is None:
self.ActiveCamera = cam
return cam
To finish updating the SceneManager class to use cameras, we need to update the Render method to set the View matrix based on the active camera. Add the following code between the return statement at the beginning and before the call to self.Device.Clear in SceneManager’s Render class:
if self.ActiveCamera is not None:
self.Device.Transform.View = self.ActiveCamera.GetViewMatrix()
Finally, we need to add code to create a default camera and have it look at the Tiger object. Add the following code to SceneCreator’s OnSceneCreate method:
cam = sm.CreateCamera("Player Cam")
cam.Position = [0, 3, -5]
cam.LookAt(self.Tiger.Position)
That’s it. If you run your application now, you will notice that really nothing has changed. This is because I cheated a little in the beginning and set up the view matrix to be in the exact same position and look at the tiger anyway. If you wish to explore the camera a bit, try moving it (or moving it around each frame).
Run your application. You should see a tiger onscreen rotating. If this is not the case, compare your code with checkpoint5.py.
We have introduced a lot of functionality in this section of the tutorial, and not made use of any of it. I would recommend that you take some time to play with the framework we have created.
Try adding more tiger meshes to the scene (remember to give them unique names when you call LoadMesh). Position them in different places around the scene. Add new OnFrame code to rotate them at different speeds, in different directions, and in multiple different axes.
Try moving an object around by setting its position incrementally every frame. Define movement speed to be a variable which can be changed. Move this change into a generic class of its own which takes in a name of the object and the speed as a parameter to the constructor.
At this point I would like to point out that there are several places in the code which can be optimized, but I did not want to muddle the tutorial with too much extra code which are not strictly necessary to understand what is going on. We will now eliminate a large number of the calculations done every frame.
The GetViewMatrix classes and GetWorldMatrix classes multiply the rotation and position matrices every time they are called. It would be better if this calculation were only done when either the PositionMatrix or the RotationMatrix variables are actually updated, and the result of this multiplication be cached in a local variable.
One simple solution to this problem would be to implement a lightweight event system which notifies a listener whenever these matrices change. This is a valid solution, but the PositionableObject and RotatableObject classes were designed to be lightweight and completely orthogonal to any other component in the system. In other words, a perfect mixin we can add to any class.
We can actually do much better than an event system thanks to the elegance Python affords us. Notice that PositionMatrix and RotationMatrix are simply variables. If we created a PositionMatrix and RotationMatrix property in the child class, we could intercept the variable assignment and perform the calculations then. The first thing we need to change is to modify the PositionableObject class so that it no longer uses a property for PositionMatrix, but a variable instead. Every time we update the position we will rebuild the matrix. Here is the PositionableObject class after we have made this change:
class PositionableObject(object):
def __init__(self):
self.__Position = DirectX.Vector3(0, 0, 0)
self.PositionMatrix = DirectX.Matrix.Identity
def __GetPosition(self):
return self.__Position
def __SetPosition(self, pos):
pos = Vectorize(pos)
self.__Position = pos
self.PositionMatrix = DirectX.Matrix.Translation(self.__Position)
def __DelPosition(self):
self.__Position = DirectX.Vector3(0, 0, 0)
self.PositionMatrix = DirectX.Matrix.Identity
def Translate(self, amount):
amount = Vectorize(amount)
self.__Position += amount
self.PositionMatrix = DirectX.Matrix.Translation(self.__Position)
Position = property(__GetPosition, __SetPosition, __DelPosition)
Now we will build a class which combines the PositionableObject and RotatableObject such that every time either the PostionMatrix or the RotationMatrix changes, it will update a variable called Matrix to contain the combined matrix. I’ll call this class SceneObject, for lack of a better term. We’ll start by defining the constructor for this class. Note we have to set our local variables first since they will actually be used within the constructors of the two ancestor classes:
class SceneObject(PositionableObject, RotatableObject):
def __init__(self):
self.Matrix = DirectX.Matrix.Identity
self.__PositionMatrix = DirectX.Matrix.Identity
self.__RotationMatrix = DirectX.Matrix.Identity
PositionableObject.__init__(self)
RotatableObject.__init__(self)
Then we need to create the get and set properties for PositionMatrix and RotationMatrix. The only thing to note here is that whenever we call the set method we update the Matrix variable as well.
def __GetPositionMatrix(self):
return self.__PositionMatrix
def __SetPositionMatrix(self, value):
self.__PositionMatrix = value
self.Matrix = self.__RotationMatrix * self.__PositionMatrix
def __GetRotationMatrix(self):
return self.__RotationMatrix
def __SetRotationMatrix(self, value):
self.__RotationMatrix = value
self.Matrix = self.__RotationMatrix * self.__PositionMatrix
PositionMatrix = property(__GetPositionMatrix, __SetPositionMatrix)
RotationMatrix = property(__GetRotationMatrix, __SetRotationMatrix)
Now we need to update any subclasses which wish to make use of this functionality. Currently the only thing we need to update is the MeshRenderable class. Update the class definition, making sure to replace the constructor calls to PositionableObject and RotatableObject with one to SceneObject:
class MeshRenderable(SceneObject):
def __init__(self, device, name, file):
SceneObject.__init__(self)
self.Name = name
# ...
Lastly, update the GetWorldMatrix method:
def GetWorldMatrix(self):
return self.Matrix
Run the application. Though there is no graphical difference in the program, we have eliminated many calculations performed with each frame.
We can load meshes with ease, but often we will want to have just a simple box, or sphere, or other basic object in our scene. This also comes in handy when we need to test code which acts on objects. To start, we will create a class which represents basic objects. We’ll use a dictionary to keep track of the constructors for each type of object:
class BasicObject(SceneObject):
ObjectTypes = {'cylinder' : Direct3D.Mesh.Cylinder,
'polygon' : Direct3D.Mesh.Polygon,
'sphere' : Direct3D.Mesh.Sphere,
'teapot' : Direct3D.Mesh.Teapot,
'torus' : Direct3D.Mesh.Torus,
'box' : Direct3D.Mesh.Box
}
Now we will create the constructor. We will take in the DirectX device, the type of the object, the name of the object, a color for the object, and then a variable number of other parameters to pass to the object’s constructor.
def __init__(self, device, type, name, color, *params):
SceneObject.__init__(self)
|
New to Python? Python allows you to define a function with a variable number of arguments by placing a star (*) in front of the variable which will hold the rest of the parameters. This must always follow all of the regular parameters.
Python also allows you to define a parameter which accepts named parameters to the function and places them in a dictionary. This is done by placing a double star (**) in front of the parameter which will receive all named attributes. See the Python documentation for examples and more information on using these constructs. |
For the type parameter, we will allow the user to either pass in a constructor for the type (callable) or a string to look up in the ObjectTypes dictionary:
if not callable(type):
type = self.ObjectTypes[type]
Now we will create the object, create a material, and set the color. Note we are expanding the params tuple into a parameter list for the constructor:
self.Name = name
self.Mesh = type(device, *params)
color = Direct3D.ColorValue.FromColor(color)
material = Direct3D.Material()
material.AmbientColor = color
self.Materials = [material]
self.Textures = []
|
New to Python? Python allows you to “expand” a list into parameters for a function. Let’s say a function takes in 3 parameters. It is perfectly valid to pass in the first parameter normally, then specify a list as the second argument and “expand” it by placing a star (*) in front of the parameter (as we have done above when calling type). |
Lastly, we need to provide the GetWorldMatrix method so that the SceneManager knows how to render it.
def GetWorldMatrix(self):
return self.Matrix
Now that the class is implemented, we need to add a method to the SceneManager which creates the class and adds it to the Objects dictionary. This is very similar to the LoadMesh method, and should be nothing new aside from the variable arguments, and argument expansion:
def LoadBasicObject(self, type, name, color, *args):
mesh = BasicObject(self.Device, type, name, color, *args)
self.Objects[mesh.Name] = mesh
return mesh
That’s it! Now we can load up basic objects. Temporarily clear out the code in the SceneCreator class and add this in its place:
def OnSceneCreate(self, sm):
teapot = sm.LoadBasicObject("teapot", "teapot", Drawing.Color.White)
cam = sm.CreateCamera("Player Cam")
cam.Position = [0, 3, -5]
cam.LookAt(teapot.Position)
Run the code and check it out. We can also load other objects which have parameters to their constructors:
sm.LoadBasicObject("box", "some box", Drawing.Color.Green, 1, 1, 1)
sm.LoadBasicObject("sphere", "some sphere", Drawing.Color.Blue, 1, 25, 25)
See the managed DirectX documentation for more information on the parameters for these constructors.
You should now replace the OnSceneCreate method with the previous one (which loads the tiger and animates it), as this was just example code to ensure our code works.
There is one last thing I feel we should look at before finishing up with this tutorial: the main function. The main function we currently have works, but it’s a bit rough around the edges. Instead, what I would like to do is create a singleton class which keeps track of our SceneManager object so we do not have to keep an explicit pointer to it. We’ll also add threading support in the mix to allow us to work with our framework from the console. Remove the main function from this program entirely.
We will not exactly use a singleton for this class. Python has a better pattern to use which maps more naturally to its language constructs. What we will do instead is to create an object whose instances all share the same dictionary (that is, all of them share the same variables and methods). This is known as the Borg pattern, and it takes only a couple of lines to implement:
class Root(object):
__State = {}
def __init__(self):
self.__dict__ = self.__ State
Note we must be very careful when defining the constructor of any Borg class. After we assign the __dict__ variable, we have synced up with the global object and should not re-initialize the object. This means that if we need to initialize any variables in the init method, we need to be careful to only do it the first time we create an instance of this object. This is usually accomplished by a boolean variable which specifies if we have initialized the class or not. Our Root class does not require any initialization, so we are in the clear.
To see this pattern in action, try this code (do not add this to the framework, instead use “import * from framework” in an IronPython console):
borg1 = Root()
borg2 = Root()
borg1.Test = “Hello World!”
print borg2.Test
Now we will add a new main function inside the root class. Note that we have kept the listeners parameter, but removed the SceneCreator default value from it.
def Main(self, listeners = []):
self.SceneManager = SceneManager()
self.Window = RenderWindow(self.SceneManager)
if not self.SceneManager.InitGraphics(self.Window.Handle):
Forms.MessageBox.Show("Could not init Direct3D.")
else:
for obj in listeners:
if callable(obj):
obj = obj()
self.SceneManager.AddListener(obj)
self.Window.Show()
self.SceneManager.Go(self.Window)
The last thing we will add to our Root class is a threaded call to Main. This will allow us to play with our framework from the console. The Python API gives us a very easy to use “thread” library. This library implements a function called start_new_thread which takes in a function to start in a new thread, and a list with the arguments to the function.
def ThreadMain(self, listeners = []):
import thread
args = (listeners,)
thread.start_new_thread(self.Main, args)
Finally, we need to update the call to the main class. Change the call to be this:
if __name__ == '__main__':
Root().Main([SceneCreator])
If you run the application now, you should see the same result as we had before starting this section. The reason we took the time to implement this change is so that we can now launch the framework in a background thread and manipulate the contents of the scene from the console.
To test this out, open up a Python console and type out the following code (do not add this to the project):
>>> from framework import *
>>> root = Root()
>>> root.ThreadMain()
>>> sm = root.SceneManager
>>> tiger = sm.LoadMesh("Tiger", "tiger.x")
>>> class TigerRotator(object):
... def OnFrame(self, ticks):
... tiger.Yaw(ticks)
...
>>> sm.AddListener(TigerRotator())
You should see the tiger rotating in the scene, and we did all of this from the console alone.
We have implemented a complex change to the code. Be sure that your application is working properly at this point. If it is not, compare your project with checkpoint6.py.
We have spent a good amount of time creating our framework, and there are many other things we could add to it. Instead of this, I thought it would be much more fun to try it out a bit.
For this portion of the tutorial we are no longer going to change the contents of the Python file we have been building. Instead we will be making a new file which contains all of our demo code we will be creating. Take the file you have been making up to this point and rename it to framework.py.
Create a new file called demo.py, and place the following contents into it:
import clr
from framework import *
class DemoSceneCreator(object):
def OnSceneCreate(self, sm):
cam = sm.CreateCamera("Player Cam")
cam.Position = [0, 3, -5]
box = sm.LoadBasicObject("box", "box 1", Drawing.Color.Red, 0.25, 0.25, 0.25)
box.Position = [-1, 0, 0]
box = sm.LoadBasicObject("box", "box 2", Drawing.Color.Green, 0.25, 0.25, 0.25)
box.Position = [0, 0, 0]
cam.LookAt(box.Position)
box = sm.LoadBasicObject("box", "box 3", Drawing.Color.Blue, 0.25, 0.25, 0.25)
box.Position = [1, 0, 0]
return True
if __name__ == '__main__':
main([DemoSceneCreator])
If you run this file now you should see three boxes and the view should be centered on the green one.
The first thing we will add is a class to move an object in a specific direction. We will create an object which stores four pieces of information: the object to act on, the direction to move in, the speed at which to move, and the distance to travel before stopping. Here is the first piece of this class:
class DirectionalMover(object):
def __init__(self, obj, direction, speed, distance):
self.Object = obj
self.Speed = speed
self.Distance = distance
self.Direction = Vectorize(direction)
self.Direction.Normalize()
The only major thing to note here is that we have normalized the direction vector, meaning that the total length of the vector is equal to one.
Now we will add code to move the object every frame. The first thing we will do is calculate the amount with which we wish to move it this frame.
def OnFrame(self, elapsed):
toTravel = self.Speed * elapsed
We have to be careful that we do not overshoot the distance we wish to travel. To keep track of this, we will constantly subtract the toTravel variable from the Distance variable. Once our Distance variable is less than the amount we would travel this frame, we translate the rest of the distance and return True (to remove ourselves from the listener pool):
if self.Distance < toTravel:
self.Object.Translate(self.Direction * self.Distance)
return True
Otherwise, subtract, and then translate:
else:
self.Distance -= toTravel
self.Object.Translate(self.Direction * toTravel)
That’s it! Now let’s test it out. Add the following method to the DemoSceneCreator class:
def OnSceneBegin(self, sm):
dm = DirectionalMover(sm.Objects["box 1"], [1, 0, 0], 0.3, 3)
sm.AddListener(dm)
dm = DirectionalMover(sm.Objects["box 2"], [0, 1, 0], 0.2, 2)
sm.AddListener(dm)
dm = DirectionalMover(sm.Objects["box 3"], [0, 0, 1], 1, 10)
sm.AddListener(dm)
return True
Now run the application. You should see the three boxes moving in different directions, reaching different locations simultaneously (after 10 seconds).
If you are having trouble with this example, compare your code with the contents of demo1.py.
Sometimes it is not convenient to simply move an object in a direction. Often we need to move an object to a location instead. What we would like, in this case, is an object mover which takes in a position to move to, and a speed at which to move.
One approach to this would be to use the class we have already created and simply figure out the distance between the current position and the position we wish to move to. This would be a valid approach in some situations, but what if the position of the object was changed by some other object in between the time the mover object was created, and the time at which the object reached its destination? Then the object would be off-target by the time it reached its destination.
To remedy this problem, we will create a new class to perform this action. This is the constructor for this class:
class PositionalMover(object):
def __init__(self, obj, destination, speed):
self.Object = obj
self.Dest = Vectorize(destination)
self.Speed = speed
In the OnFrame method, we need to calculate the distance we will travel that frame, the distance we have left to travel (so we do not overshoot our destination), and the direction we will travel in.
def OnFrame(self, elapsed):
toTravel = self.Speed * elapsed
direction = self.Dest - self.Object.Position
distance = direction.Length()
direction.Normalize()
Once we have this information, the rest of our method should look very familiar.
if distance < toTravel:
self.Object.Translate(direction * distance)
return True
else:
self.Object.Translate(direction * toTravel)
Note that we are doing a lot more calculations per frame in PositionalMover than we did in DirectionalMover (normalizing a vector is a costly operation as it involves a square root). We have to be careful how often we use this class, as it could possibly bog down our application if we overused it. However, this would not really become a concern until we are moving a large amount of objects with this class.
Now lets test our class. Clear out the contents of DemoSceneCreator’s OnSceneBegin method, if you have not already, and add this:
def OnSceneBegin(self, sm):
pm = PositionalMover(sm.Objects["box 1"], [2, 0, 0], 0.3)
sm.AddListener(pm)
pm = PositionalMover(sm.Objects["box 2"], [0, 2, 0], 0.2)
sm.AddListener(pm)
pm = PositionalMover(sm.Objects["box 3"], [1, 0, 10], 1)
sm.AddListener(pm)
return True
The results should be exactly the same as before. We have moved the boxes to the same locations at the same speed. Try playing around with the locations the boxes move to, and the speed at which they move. Try adding both a PositonalMover and DirectionalMover affectors to the same object (be sure to register both listeners with the SceneManager) and watch the effects.
If you are having trouble with this example, compare your code with the contents of demo2.py.
This is very cool, but still, there are many situations where simply moving from a location to another location would not be enough. Sometimes we need to set up a list of points for an object (or character) to walk between. This can be accomplished by keeping track of a set of points (and speeds at which to travel to these points) and delegating the actual movement task to PositionalMover.
We will implement this class as PointMover, for lack of a better term. The constructor for this object will take in only one parameter, the object to move. The object will also keep track of a list of points to walk between, and the current PositionableMover object (which is doing the actual work).
class PointMover(object):
def __init__(self, obj):
self.Object = obj
self.Points = []
self.Current = None
Now we need to create a method to add points for the object to move to. This method will take two parameters: the point to move to and the speed at which to move. We’ll simply throw this into the Points list as a tuple.
def AddPoint(self, point, speed):
self.Points.append((point, speed))
Now we need to implement a method which sets the Current variable by taking the first item in the list and creating a PositionalMover for it. We will return True if we are successful in creating the object:
def NextPoint(self):
if len(self.Points):
dest, speed = self.Points[0]
del self.Points[0]
self.Current = PositionalMover(self.Object, dest, speed)
return True
Notice that we have not added a return False to the end of this method. This would be valid, but unnecessary. Whenever you do not return a value, you are implicitly returning None. Since None evaluates to be false, you actually are returning false implicitly whenever you do not return anything. (Or more specifically, you are returning an expression which evaluates to False.)
Now that we have all of the helper methods that are required, we are ready to create the OnFrame method. The first part of the method will handle the first call to the method (in which case self.Current is None). If it is None then we will attempt to set it by calling NextPoint. If NextPoint returns False then the user never bothered to add any points, and we will return True to tell SceneManager that we are done:
def OnFrame(self, elapsed):
if self.Current is None:
if not self.NextPoint():
return True
Now that we have setup self.Current, we need to actually move the object. As soon as self.Current.OnFrame returns false, we need to move onto the point…if there are any left. This is the rest of the method:
if self.Current.OnFrame(elapsed):
return not self.NextPoint()
Now that we have implemented this class, it’s time to test it. Clear out the contents of DemoSceneCreator’s OnSceneBegin method, if you have not already. Place this code there:
def OnSceneBegin(self, sm):
pm = PointMover(sm.Objects["box 1"])
pm.AddPoint( [2, -1, 0], 1 )
pm.AddPoint( [0, 1, 2], 1 )
pm.AddPoint( [0, 0, 10], 1 )
sm.AddListener(pm)
pm = PointMover(sm.Objects["box 2"])
pm.AddPoint( [2, 1, 0], 1 )
pm.AddPoint( [-1, -3, 5], 1 )
pm.AddPoint( [-1, 0, 10], 1 )
sm.AddListener(pm)
pm = PointMover(sm.Objects["box 3"])
pm.AddPoint( [-2, -1, 0], 1 )
pm.AddPoint( [1, -3, 0], 1 )
pm.AddPoint( [1, 0, 10], 1.15 )
sm.AddListener(pm)
return True
Run the program to watch the cubes dart around the scene. Take some time to play around with the points the objects move to, and the speed at which they move.
If you are having trouble with this example, compare your code with the contents of demo3.py.
It would be very useful in many situations to make the camera (or other object) “track” something on screen. That is, every time the object moves, the camera is readjusted to face it. Fortunately for us, this is a nearly trivial thing to implement with the framework we have created:
class AutoTrack(object):
def __init__(self, obj, target):
self.Object = obj
self.Target = target
def OnFrame(self, elapsed):
self.Object.LookAt(self.Target.Position)
Replace DemoSceneCreator’s OnSceneBegin method with the following code:
def OnSceneBegin(self, sm):
pm = PointMover(sm.Objects["box 1"])
pm.AddPoint( [2, -1, 0], 1 )
pm.AddPoint( [0, 1, 2], 1 )
pm.AddPoint( [0, 0, 10], 1 )
sm.AddListener(pm)
pm = PointMover(sm.Objects["box 2"])
pm.AddPoint( [2, 1, 0], 1 )
pm.AddPoint( [-1, -3, 5], 1 )
pm.AddPoint( [-1, 0, 10], 1 )
sm.AddListener(pm)
pm = PointMover(sm.Objects["box 3"])
pm.AddPoint( [-2, -1, 0], 1 )
pm.AddPoint( [1, -3, 0], 1 )
pm.AddPoint( [1, 0, 10], 1.15 )
sm.AddListener(pm)
sm.AddListener( AutoTrack(sm.Objects["Player Cam"], sm.Objects["box 3"]) )
return True
Run the demo. This probably looks very strange. You will see one box not moving, while the other two fly around. This shows that our camera is “tracking” the object we have specified. To confirm this, comment out the line which adds the AutoTrack to the SceneManager. The boxes move as normal. The reason this looks so strange is because we have not set a background to contrast the movement of the boxes. This would look a lot more natural in a real-world application.
If you are having trouble with this example, compare your code with the contents of demo4.py.
I have saved the best part of the tutorial for last. In this section, we will implement a gravity simulator. The simulator will allow you to create any number of free floating bodies in space (like planets) by setting their mass, position, initial velocity, and color (the color is just for visual distinction).
The first thing we need to do is to create a class which understands how to apply the physics of gravity to a particular object. This object will keep track of an object’s Mass, Velocity, Acceleration, and a list of objects which are affecting the object. Additionally we will keep track of the object’s radius to handle what happens when objects get too close to each other. The constructor for this class is very simple, taking in a few of these as parameters and setting up variables:
class GravityAffector(object):
def __init__(self, obj, mass, radius, initialVelocity):
self.Object = obj
self.Radius = radius
self.Affectors = []
self.Mass = mass
self.Velocity = Vectorize(initialVelocity)
self.Acceleration = DirectX.Vector3(0,0,0)
Now we need to create a method which figures out exactly how much acceleration is being applied to an object at any given moment. The result will be stored in the self.Acceleration variable.
def ApplyGravity(self):
self.Acceleration = DirectX.Vector3(0,0,0)
Now we need to loop through all of the objects affecting the current object and apply Newton’s law of universal gravitation to the current object. To simplify a lot of later code, we will assume the self.Affectors list could possibly contain the object we are acting on. In this case we’ll simply ignore it.
for obj in self.Affectors:
if obj != self.Object:
To calculate the affect of gravity, we need to know the direction (unit vector) that the force is acting in, and the squared distance between the two objects.
direction = obj.Object.Position - self.Object.Position
distSquared = direction.LengthSquared()
direction.Normalize()
Finally, we need to calculate the change in acceleration due to this object. There is one small catch though. We are not doing any collision detection in this piece of code, and if objects are too close to each other the gravitation formulas explode, giving accelerations which should be technically impossible. To fix this problem, we only apply gravity when the squared distance is greater than the radius of the object. So long as the radius is set to be a reasonable amount (say 0.05 units), this will fix the problem of planets having acceleration they should not have when “colliding.”
if distSquared > self.Radius:
self.Acceleration += direction * (obj.Mass / distSquared)
Any physicist who just read that probably physically cringed at this equation. This is not exactly Newton’s law of universal gravitation. However, since we are arbitrarily defining the mass of our objects, this should be good enough for our simulation.
Finally, during each frame we need to apply the acceleration to the velocity of the object, and apply the velocity to the position of the object.
def OnFrame(self, elapsed):
self.Velocity += self.Acceleration * elapsed
self.Object.Translate(self.Velocity * elapsed)
We now need to ensure that the acceleration of all objects is calculated before we apply it. We do not want to move any object before all other objects get the position of the object. In theory, the small amount of motion should not affect the overall mechanics of the scene, but in practice our scene will have few enough objects (close enough together) that this will actually impact it.
To solve this, we will create a GravityManager class which calls the ApplyGravity methods of all objects before it calls the OnFrame method. Here is the code for this class:
class GravityManager(object):
def __init__(self, objs = []):
self.Objects = objs
def OnFrame(self, elapsed):
for obj in self.Objects:
obj.ApplyGravity()
for obj in self.Objects:
obj.OnFrame(elapsed)
Now we are almost ready to create our scene, but there is one final snag we must correct before we are ready to do that. When we start our simulation up, all of the objects we have created will go flying around the scene. We need the camera to watch all of them so that we can see what is going on. The AutoTrack class we have already created seems like a good choice, but objects could move too far to actually be seen.
Our solution will be a new auto tracking class which finds the midpoint of all objects on the screen and looks at that instead. It will also always stay a specified distance from the midpoint, so objects have a harder time moving off camera. I will warn you up front that this is not a complete solution (objects will still fall off screen eventually), but it should at least allow us to watch our simulation for longer than our original AutoTrack class would.
The constructor for this class should take in the object we will be operating on (the camera in this case), the list of objects to find the midpoint for, and a distance (offset) to stay from this midpoint.
class MidpointAutoTrack(object):
def __init__(self, obj, others, offset):
self.Object = obj
self.Others = others
self.Offset = Vectorize(offset)
The OnFrame method will calculate the midpoint for the objects and have the camera object look at it. We will first check to see if the self.Others variable is a sequence. If it is not we will assume the user just passed in a singular object. Otherwise, we will store the length of our self.Others sequence in the l variable:
def OnFrame(self, elapsed):
l = None
pos = DirectX.Vector3(0, 0, 0)
if type(self.Others)==list:
l = len(self.Others)
if l is None:
pos = self.Others.Position
We need to handle the case where the length is equal to one specially:
elif l == 1:
pos = self.Others[0].Position
If we did not do this, we would get a divide by zero error in the code which handles the midpoint calculation:
elif l > 1:
for o in self.Others:
pos += o.Position
pos.X /= l-1
pos.Y /= l-1
pos.Z /= l-1
Now that we have calculated the position, we need to set the position of the current object and have it look at the calculated position:
self.Object.Position = pos + self.Offset
self.Object.LookAt(pos)
This completes our MidpointAutoTrack class.
Now that we have all of the code we need, let’s create the scene. The first thing we will do is create a list which contains information for all of the objects we will place in the scene. By building this list first (and then using this list to actually create the scene), we are making it much easier to add and remove objects from the scene later.
class GravityDemoCreator(object):
def OnSceneCreate(self, sm):
objs = []
Next we will populate the objs list with tuples containing the color, mass, position, and velocity of each object in the scene. Note that I have purposefully commented out the last line. The first couple of times you run the code you should only have three objects in the scene (since the gravity for this is much more stable). You should uncomment this line to add a fourth after you have played with the code with only 3 objects.
# add: color, mass, poition, velocity
objs.Add( (Drawing.Color.Red, 1, [-1, 0, 0], [0.05, 0.05, 0]) )
objs.Add( (Drawing.Color.Green, 1, [0, 1, 0], [0.1, 0, 0]) )
objs.Add( (Drawing.Color.Blue, 1, [1, 0, 1], [-0.1, 0, 0]) )
#objs.Add( (Drawing.Color.White, 1, [0, 0, 1], [0, 0, 0]) )
Now we will loop through the objects, placing the created object in a list called finalObjects, and placing a GravityAffector for the objects into a list called affectors:
radius = 0.05
affectors = []
finalObjects = []
i = 0
for color, mass, position, velocity in objs:
sphere = sm.LoadBasicObject("sphere", "planet " + str(i), color, radius, 25, 25)
sphere.Position = position
affectors.Add( GravityAffector(sphere, mass, radius, velocity) )
finalObjects.Add(sphere)
i += 1
Now that we have created all of the GravityAffector objects, we need to set them to affect each other. We do that by setting the Affectors variable on all of them. After this is done, we need to add create the GravityManager class and add it to the SceneManager.
for g in affectors:
g.Affectors = affectors
gm = GravityManager(affectors)
sm.AddListener(gm)
The last thing we need to do in this method is to create the camera and have it autotrack the midpoint of all of these items:
cam = sm.CreateCamera("Player Cam")
sm.AddListener(MidpointAutoTrack(cam, finalObjects, [0, 0, 15]))
return True
Finally, we need to update the call to the main function such that it uses the GravityDemoCreator. Find that section of code and change it so it reads:
if __name__ == '__main__':
Root().Main([GravityDemoCreator])
Run the application and watch the simulation. Be sure to run it multiple times, as you will get different results each time.
After running this several times, you may notice some peculiar things happening with our simulation. First and foremost, why do we get different results each time we run the program? Nowhere did we make a call to a random number generator… The reason for this is floating point rounding errors. If you add code to dump the current velocity and acceleration of each object to a file every frame, you will notice that after a frame or two small rounding errors explode into large changes to the overall system. Chaos theory at work. We could fix this by using a more accurate floating point class (and chopping off the rounding error portions based on the accuracy of this class).
Another strange thing is, if you run the program long enough, you may notice that some objects occasionally “wobble” in ways which seem unnatural. This is due to the fact that our camera is not actually stationary, but moving and changing directions every frame to keep the moving objects on screen. A lot of the weirdness of object “wobble” goes away if you simply turn off camera movement…but good luck keeping them on screen if you do not move the camera to keep up with them.
Try adding more objects to the system and changing the mass of objects (along with the other initial conditions of the system).
If you are having trouble with this example, compare your code with the contents of GravityDemo.py.
I hope you have enjoyed working through this tutorial as much as I have enjoyed making it. There are several resources I would like to point out if you are interested in further study of IronPython or DirectX.
Managed DirectX 1 (which this tutorial makes heavy use of) is not scheduled for any future updates. Instead, Microsoft made a new managed 3D library built on top of DirectX called XNA. You can find more information on XNA on their web site:
You can find more information on IronPython by visiting the IronPython Codeplex site:
http://www.codeplex.com/IronPython
You can subscribe to the IronPython mailing list for any questions you have about the project:
http://lists.ironpython.com/listinfo.cgi/users-ironpython.com
There are also several blogs from the people who use IronPython on a daily basis. I suggest starting here:
http://blogs.msdn.com/ironpython/
If you are a graphics hobbyist like I am, you may wish to continue extending this framework. It already has the basic features for positioning and rotating objects, however, what it truly lacks is extra functionality to handle more complex meshes and materials. I have implemented some of these things in the framework.py file which comes with this tutorial, but I did not have enough time to roll these changes back into the tutorial. If you are interested, read through the framework Python module to get a good feel for the updates.