Hybrid promises to be a viable tool for creating tools for games and multimedia apps. So far, there has been little proof of this. How about creating a simple model viewer with 3 ortho wireless viewports and a perspective-projected one?
Creating an OpenGL viewport with Hybrid is pretty much trivial, so we'll make it cool by adding a Spinners to control offsets and zoom for each viewport.
First off, how do we load a simple 3d model? We might try loading some .3ds, .obj or .ase file, but writing a parser for any of these formats is not in the scope of this tutorial. What then? Luckily, the xf package contains a loader for team0xf's Hme scene format. hme files are simple human-readable config-like files exported by our custom 3ds Max exporter written in MaxScript.
The HmeLoader yields a scene description composed of a hierarchy of Nodes, Meshes and a few other classes. They are all game/engine-neutral and meant as an intermediate data transfer format.
In order to manage a viewport and its settings in a sane manner, we'll create a custom class for it, SceneView.
Additionally, we're going to create a very simple custom widget class which will encapsulate the zoom and offsets for a viewport.
This tutorial will provide code chunks in roughly the order they should be composed. At the end, the complete code will be presented. Let's get it started.
Imports
First, we need to pull in a few tons of stuff to make our program work without re-inventing the wheel too much.
import xf.hybrid.Hybrid;
import xf.hybrid.backend.GL;
import xf.hybrid.Common;
import xf.hybrid.CustomWidget;
Since we're about to create a custom widget, we needed to import two modules: CustomWidget and Common. The former defines a widget that is configured using configs. The latter contains imports that custom widgets and their mixins need.
import xf.loader.scene.Hme;
import xf.loader.scene.model.Node;
import xf.loader.scene.model.Mesh;
import xf.loader.scene.model.Scene;
Here we've imported the Hme format loader as well as modules for a few components of the intermediate scene hierarchy.
import xf.omg.core.LinearAlgebra;
import xf.omg.core.CoordSys;
import xf.omg.core.Misc;
OMG (Open Math for Games) provides us with useful linear algebra utilities. This includes vectors, quaternions, matrices, misc stuff and CoordSys. The latter wraps a 3D vector based on fixed - point numbers along with a quaternion to represent a coordinate system. We won't need any more for manipulating 3D objects.
Finally, we'll import Dog - the D OpenGL wrapper from team0xf. We'll use it for direct rendering using a GLViewport.
We'll skip two misc Tango module imports and follow directly into our custom widget's declaration:
class ViewportControls : CustomWidget {
mixin(defineProperties("float zoom, float x, float y, float z"));
mixin MWidget;
}
That wasn't hard, was it? This defines a custom widget class which we must configure in the config files. The defineProperties CTFE code generator will produce four properties, zoom, x, y and z, all of type float. They are not inline properties, so they need to be bound to something in the config files or they'll be useless. So let's define our custom widget in a ModelViewer.cfg file:
widget ViewportControls {
layout = HBox;
[hexpand hfill] new VBox {
new Label { text = "zoom"; fontSize = 10; }
new FloatInputSpinner zoom;
}
[hexpand hfill] new VBox {
new Label { text = "x offset"; fontSize = 10; }
new FloatInputSpinner xoff;
}
[hexpand hfill] new VBox {
new Label { text = "y offset"; fontSize = 10; }
new FloatInputSpinner yoff;
}
[hexpand hfill] new VBox {
new Label { text = "z offset"; fontSize = 10; }
new FloatInputSpinner zoff;
}
zoom = prop(zoom.value);
x = prop(xoff.value);
y = prop(yoff.value);
z = prop(zoff.value);
}
We've defined ViewportControls to be composed of four vertical boxes, each containing a label explaining the meaning of a FloatInputSpinner below it. At the end of the custom widget type declaration, we bind the properties declared in the D class using defineProperties to concrete properties within the float spinners.
The ModelViewer.cfg will also contain the layout, but we'll skip it as it's nothing relevant at the moment.
Rendering the scene
Let's create the SceneView class for managing a single viewport. The beginning will look like this:
class SceneView {
CoordSys coordSys;
CoordSys objCS = CoordSys.identity;
bool ortho;
Scene scene;
Light[] lights;
bool wireframe;
float zoom = 0.f;
What do we have here?
- coordSys is the transformation applied to our camera
- objCS is the transformation applied to the scene within
- ortho determines whether the viewport uses orthogonal or perspective projection
- scene is the scene that will be displayed. It's an instance of a class from xf.loader.scene.model
- lights make the model not look like crap
- wireframe determines whether the scene is rendered as lines or solid-filled polygons
- zoom will be positive during magnification
We'll skip the most of the SceneView class' body, since it's mostly a few dozen OpenGL calls, but let's take a look at how the actual scene nodes and meshes are processed:
void renderNode(GL gl, Node node, CoordSys cs) {
foreach (n; node.children) {
renderNode(gl, n, n.coordSys in cs);
}
foreach (m; node.meshes) {
renderMesh(gl, m, m.coordSys in cs);
}
}
The Node class holds stuff like animations, lights, etc, but in this case, we're only interested in its children and static meshes. Let's now draw the meshes:
void renderMesh(GL gl, Mesh mesh, CoordSys cs) {
gl.PushMatrix();
gl.MultMatrixf(cs.toMatrix.ptr);
gl.immediate(GL_TRIANGLES, {
gl.Color4f(1, 1, 1, 1);
foreach (i; mesh.indices) {
if (mesh.normals.length > 0) {
gl.Normal3fv(mesh.normals[i].ptr);
}
gl.Vertex3fv(mesh.positions[i].ptr);
}
});
gl.PopMatrix();
}
gl.immediate does glBegin(...); inlineDelegateCall(); glEnd();, the rest should be self-explanatory.
Creating the GUI
Since we've created all dependencies, let's put it all together - create the SceneViews, ViewportControls and some basic controls to load and unload the scene. Let's skip the usual Hybrid initialization and create the SceneViews. Please note that we're currently outside of the main loop.
SceneView[4] views;
views[0] = new SceneView(
CoordSys(vec3fi.zero, quat.xRotation(-45)), false, false);
views[1] = new SceneView(
CoordSys(vec3fi.zero, quat.identity), true, true);
views[2] = new SceneView(
CoordSys(vec3fi.zero, quat.xRotation(-90)), true, true);
views[3] = new SceneView(
CoordSys(vec3fi.zero, quat.yRotation(90)), true, true);
With this setup, the four viewports should be: top, front, left, perspective. Only perspective will be solid-filled.
As for loading/unloading the scene, this snippet of code inside the main loop will handle it:
if (Button(`loadButton`).clicked) {
char[] path = Input(`pathInput`).text;
try {
scope loader = new HmeLoader;
loader.load(path);
foreach (v; views) {
v.scene = loader.scene;
}
} catch (Exception e) {
Trace.formatln("Cannot load the scene: {}", e.toString);
}
}
if (Button(`unloadButton`).clicked) {
foreach (v; views) {
v.scene = null;
}
}
I hope that wasn't too complex to grasp. Hold on, we're almost there. The only thing remaining is to create the GLViewport widgets and update the SceneView properties using data from ViewportControls. This is all handled by this short piece of code:
foreach (i, c; ['0', '1', '2', '3']) {
GLViewport(`view`~c).renderingHandler = &views[i].draw;
auto ctrl = ViewportControls(`view`~c~`ctrl`);
views[i].coordSys.origin = vec3fi[ctrl.x, ctrl.y, ctrl.z];
views[i].zoom = ctrl.zoom;
}
Result
That wasn't a lot of code and it doesn't even get much longer when we add all the stuff we've skipped. Without further ado, here's the result:
Source code
This sample can be found in hybrid/demos/modelViewer, but as initially promised, here's the complete source code:
module ModelViewer;
private {
import xf.hybrid.Hybrid;
import xf.hybrid.backend.GL;
import xf.hybrid.Common;
import xf.hybrid.CustomWidget;
import xf.loader.scene.Hme;
import xf.loader.scene.model.Node;
import xf.loader.scene.model.Mesh;
import xf.loader.scene.model.Scene;
import xf.omg.core.LinearAlgebra;
import xf.omg.core.CoordSys;
import xf.omg.core.Misc;
import xf.dog.Dog;
import tango.core.Thread;
import tango.util.log.Trace;
}
class ViewportControls : CustomWidget {
mixin(defineProperties("float zoom, float x, float y, float z"));
mixin MWidget;
}
void main() {
version (DontMountExtra) {} else gui.vfsMountDir(`../../`);
scope cfg = loadHybridConfig(`./ModelViewer.cfg`);
scope renderer = new Renderer;
SceneView[4] views;
views[0] = new SceneView(
CoordSys(vec3fi.zero, quat.xRotation(-45)), false, false);
views[1] = new SceneView(
CoordSys(vec3fi.zero, quat.identity), true, true);
views[2] = new SceneView(
CoordSys(vec3fi.zero, quat.xRotation(-90)), true, true);
views[3] = new SceneView(
CoordSys(vec3fi.zero, quat.yRotation(90)), true, true);
gui.begin(cfg);
with (ViewportControls(`main.view0ctrl`)) {
y = 1;
z = 1;
}
gui.end();
bool programRunning = true;
while (programRunning) {
gui.begin(cfg).push(`main`);
if (gui().getProperty!(bool)("frame.closeClicked")) {
programRunning = false;
}
if (Button(`loadButton`).clicked) {
char[] path = Input(`pathInput`).text;
try {
scope loader = new HmeLoader;
loader.load(path);
foreach (v; views) {
v.scene = loader.scene;
}
} catch (Exception e) {
Trace.formatln("Cannot load the scene: {}", e.toString);
}
}
if (Button(`unloadButton`).clicked) {
foreach (v; views) {
v.scene = null;
}
}
foreach (i, c; ['0', '1', '2', '3']) {
GLViewport(`view`~c).renderingHandler = &views[i].draw;
auto ctrl = ViewportControls(`view`~c~`ctrl`);
views[i].coordSys.origin = vec3fi[ctrl.x, ctrl.y, ctrl.z];
views[i].zoom = ctrl.zoom;
}
gui.pop.end();
gui.render(renderer);
Thread.yield();
}
}
class SceneView {
CoordSys coordSys;
CoordSys objCS = CoordSys.identity;
bool ortho;
Scene scene;
Light[] lights;
bool wireframe;
float zoom = 0.f;
static class Light {
this (vec3 from, vec3 col, int lightId) {
this.lightId = lightId;
this.col = vec4(col.x, col.y, col.z, 0);
this.from = vec4(from.x, from.y, from.z, 1);
}
void use(GL gl) {
int lightId = GL_LIGHT0 + this.lightId;
gl.Lightfv(lightId, GL_DIFFUSE, &col.x);
gl.Lightfv(lightId, GL_POSITION, &from.x);
gl.Enable(lightId);
}
int lightId;
vec4 col, from;
}
this (CoordSys coordSys, bool ortho, bool wireframe) {
this.coordSys = coordSys;
this.ortho = ortho;
this.wireframe = wireframe;
lights ~= new Light(vec3(0, 1, 1), vec3(0.4, 0.5, 0.4), 0);
lights ~= new Light(vec3(1, 1, 0), vec3(0.4, 0.4, 0.7), 1);
lights ~= new Light(vec3(-1, 1, -1), vec3(0.8, 0.8, 0.4), 2);
lights ~= new Light(vec3(0, -1, 0), vec3(0.2, 0.5, 0.2), 3);
}
void draw(vec2i size, GL gl) {
if (scene is null) {
return;
}
gl.MatrixMode(GL_PROJECTION);
gl.LoadIdentity();
float aspect = cast(float)size.x / size.y;
if (ortho) {
float scale = pow(2, -zoom);
gl.Ortho(scale*-aspect, scale*aspect, scale*-1, scale*1, -100, 100);
} else {
float fov = 90.f * pow(2, -zoom);
gl.gluPerspective(fov, aspect, 0.1f, 100.f);
}
gl.MatrixMode(GL_MODELVIEW);
gl.LoadIdentity();
gl.Clear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
CoordSys viewCS = coordSys.inverse;
gl.LoadMatrixf(viewCS.toMatrix.ptr);
foreach (l; lights) {
l.use(gl);
}
if (wireframe) {
gl.PolygonMode(GL_FRONT_AND_BACK, GL_LINE);
} else {
gl.PolygonMode(GL_FRONT_AND_BACK, GL_FILL);
}
gl.withState(GL_DEPTH_TEST).withState(GL_LIGHTING) in {
foreach (node; scene.nodes) {
renderNode(gl, node, node.coordSys in objCS);
}
};
}
void renderNode(GL gl, Node node, CoordSys cs) {
foreach (n; node.children) {
renderNode(gl, n, n.coordSys in cs);
}
foreach (m; node.meshes) {
renderMesh(gl, m, m.coordSys in cs);
}
}
void renderMesh(GL gl, Mesh mesh, CoordSys cs) {
gl.PushMatrix();
gl.MultMatrixf(cs.toMatrix.ptr);
gl.immediate(GL_TRIANGLES, {
gl.Color4f(1, 1, 1, 1);
foreach (i; mesh.indices) {
if (mesh.normals.length > 0) {
gl.Normal3fv(mesh.normals[i].ptr);
}
gl.Vertex3fv(mesh.positions[i].ptr);
}
});
gl.PopMatrix();
}
}
GUI Config
And the accompanying config containing our custom widget type definition and a layout for the window:
import "themes/default.cfg"
new FramedTopLevelWindow main {
frame.text = "Hybrid Model viewer";
layout = {
padding = 3 3;
spacing = 2;
}
new HBox {
layout = {
spacing = 2;
}
[vfill vexpand] new Input pathInput {
size = 400 0;
text = "scenes/teapot/scene.hme";
}
[vfill vexpand] new Button loadButton {
text = "Load";
}
[vfill vexpand] new Button unloadButton {
text = "Unload";
}
}
new Dummy {
size = 0 20;
}
new HBox {
layout = {
spacing = 2;
}
new VBox {
view1 {
size = 320 240;
}
[hexpand hfill] new ViewportControls view1ctrl;
}
new VBox {
view2 {
size = 320 240;
}
[hexpand hfill] new ViewportControls view2ctrl;
}
}
new HBox {
layout = {
spacing = 2;
}
new VBox {
view3 {
size = 320 240;
}
[hexpand hfill] new ViewportControls view3ctrl;
}
new VBox {
view0 {
size = 320 240;
}
[hexpand hfill] new ViewportControls view0ctrl;
}
}
} @overlay {
[hexpand vexpand hfill vfill] new Group .overlay {
layout = Ghost;
}
}
widget ViewportControls {
layout = HBox;
[hexpand hfill] new VBox {
new Label { text = "zoom"; fontSize = 10; }
new FloatInputSpinner zoom;
}
[hexpand hfill] new VBox {
new Label { text = "x offset"; fontSize = 10; }
new FloatInputSpinner xoff;
}
[hexpand hfill] new VBox {
new Label { text = "y offset"; fontSize = 10; }
new FloatInputSpinner yoff;
}
[hexpand hfill] new VBox {
new Label { text = "z offset"; fontSize = 10; }
new FloatInputSpinner zoff;
}
zoom = prop(zoom.value);
x = prop(xoff.value);
y = prop(yoff.value);
z = prop(zoff.value);
}