Skip to content

Entity tree library / sample project for the LOVR vr game engine

License

Notifications You must be signed in to change notification settings

alloverse/lovr-ent

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

19 Commits
 
 
 
 
 
 

Repository files navigation

This repo contains my "starting point project" for games or other Lua software written using the LÖVR VR games engine. The contents were written by me, Andi McClure <[email protected]>, with some open source libraries included, and are the basis for games under development for Mermaid Heavy Industries.

The software in here is mostly a hodgepodge of "whatever I need", but the core is an entity tree library, hence the name. Also included are

  • A simple 2D UI library for LÖVR's on-monitor "mirror" window, useful for debug UI.
  • Modified versions of the CPML (vector math) and Penlight (classes and various Lua utilities) libraries
  • My namespace.lua library
  • Helper code for making thread tools, and one class for offloading asset loading onto a side thread
  • A debug class for placing temporary cube and line markers at important points in space
  • A standalone app to preview 3D model files and inspect their materials, animation nodes and animations.

A map of all included files is in contents.txt. The license information is here. I have a page with more LÖVR resources here.

This code assumes LÖVR version 0.13.

Why use this?

Let's take a look at the "cube.lua" example program packaged in the repo:

-- Simple "draw some cubes" test
namespace("cubetest", "standard")

local CubeTest = classNamed("CubeTest", Ent)
local shader = require "shader/shader"

function CubeTest:onLoad(dt)
	self.time = 0
end

function CubeTest:onUpdate(dt)
	self.time = self.time + math.max(dt, 0.05)
end

function CubeTest:onDraw(dt)
	lovr.graphics.clear(1,1,1) lovr.graphics.setShader(shader)

	local count, width, spacing = 5, 0.4, 2
	local function toColor(i) return (i-1)/(count-1) end
	local function toCube(i) local span = count*spacing return -span/2 + span*toColor(i) end

	for x=1,count do for y=1,count do for z=1,count do
		lovr.graphics.setColor(toColor(x), toColor(y), toColor(z))
		lovr.graphics.cube('fill', toCube(x),toCube(y),toCube(z), cubeWidth, self.time/8, -y, x, -z)
	end end end
end

return CubeTest

If you've already used LÖVR, this looks a lot like a normal LÖVR program-- instead of implementing lovr.update() it implements CubeTest:onUpdate(dt). But it's set up a little different and this gives us some neat advantages. Because this program is enclosed in an object (an "entity"), we could swap it out for another "entity" program very easily, or run it at the same time as another "entity" program. In my main game project, I have a variety of small helper programs in the same repo, which let me test or edit various parts of the game; I use the command line to decide which ones I want to run. Below there's an example where the command line is used to tell lovr-ent to run the cubes program at the same time as another program that displays the FPS in the corner. It would also be easy to write a program where the main program's "entity" loaded a copy, or several copies with different parameters, of the CubeTest entity as children and presented them in a scene.

You'll also notice the "namespace" tag at the top of the file. This takes away the risk of accidentally letting globals from one file contaminate other files-- globals will only be shared between the .lua files that start with namespace "cubetest".

How to use this

You want to copy the lua folder in this repo into your own repo (or just develop inside this repo if you want to be able to merge future updates). If you're using lovr-oculus-mobile, you could also add the path to this repo's lua folder to assets.srcDirs in "build.gradle" and the files will be merged with yours when you build.

You should take a look at main.lua. There's some stuff here you probably want to change: There's a list of modules imported from CPML and Penlight. There's a section labeled "Suggest you create a namespace for your game here", which you probably want to uncomment, and set up the globals for your own game's namespace there. You also want to change the "defaultApp" variable to point to your main Ent.

Now you'll want to start adding your own .lua files to the project, for your main Ent and any helper stuff your game needs. I use the app/ directory to store entities that could potentially be run alone as the main Ent, an ent/ directory to store reusable entities that another Ent might load as a child, the engine/ directory to store other helper files, and level/ and resource/ directories to store my helper files. But you can do it however.

Using Ents

The first thing to know is Ents are classes, using the Penlight class library (see here, or "Simplifying Object-Oriented Programming in Lua" here). You probably need to understand what "Classes", "Objects", "Inheritance" and "Instances" are to go any further, and you need to understand the difference between . and : in Lua.

Entities are instances of Ent (or any class inheriting from Ent). Every entity keeps a list of child entities. When events occur-- the program boots, there is an update, it is time to draw-- those events are "routed" to every living Ent, starting with the "root" Ent. Some events are:

* onLoad: Equivalent of lovr.load
* onUpdate: Equivalent of lovr.update
* onDraw: Equivalent of lovr.draw
* onMirror: Equivalent of lovr.mirror

If, say, "onDraw" fires, then for each entity starting with ent.root that entity calls its onDraw() function (if it has one), and then for each of its children in turn they call their onDraw() (if they have one) and repeat with their children. (The children don't get called in any particular order, except for entities that inherit from OrderedEnt.) You can route an event to every object yourself by calling ent.root:route("onEventName", OPTIONALARGS), and every loaded entity will get the function call onEventName(OPTIONALARGS).

So Ents live in a tree of entities. If you've used Unity, Ents are kind of like a combination of Components, gameObjects and scenes. (You can't at the moment give an Ent an inheritable "transform" or world position, but this may appear in a later version of lovr-ent.)

Ent lifecycle

To create an Ent, you call its constructor; the default constructor for Ents takes a table as argument, and assigns all fields to the entity object. So if you say YourEnt{speed=3}, this creates a YourEnt object where self.speed is 3 in all methods. Once you've constructed the Ent, you need to insert it to add it to the tree: call insert( PARENT ) with the . If you don't list a parent the entity will automatically add itself to ent.root, but usually Ents will be created by methods of other Ents, so you'll want to set self as the parent.

By the way, the "onLoad" event is special. It is called not just when lovr.onLoad() is called, but also when any object is insert()ed to an object which is a child of the root if lovr.onLoad() has already been called. This means most of the things you'd normally do in a constructor, like setting default values for variables, it's smarter to do in onLoad, since that code will be called only when the object "goes live".

When you're done with an Ent, call yourEnt:die(). This registers your Ent to be removed from the tree (which will remove all its children as well) at the end of the current frame. You'll get an "onDie" event call if you or one of your parents gets died, which you can use to do any cleanup.

An interesting thing about the Ent default constructor is that you can do one-off entities by overloading the event methods in the constructor. Here's what I mean:

Ent{ onUpdate = function(self, dt) print("Updated! dt:" .. dt) end }:insert()

Running this code will create and attach to the root an object that prints the current frame's timestep on every frame.

Using LoaderEnt

LoaderEnt is a built-in entity that loads and runs Ent classes from disk. The root entity is a LoaderEnt, and it loads the classes in the command line. So if you launch your game by running:

lovr lovr-ent/lua app/test/testUi

Then lovr-ent will load and run the class in the file "app/test/testUi.lua" (it will require() "app/test/testUi", construct the class it returns, and call insert()). We can get fancier if we download and add in my other LÖVR helper tool, Lodr:

lovr lovr-lodr lovr-ent/lua app/test/cube app/debug/hand app/debug/fps

What's happening here? Well, lovr loads Lodr, which loads lovr-ent, which loads each of the "cube" sample app, and two helper apps that respectively display the handset controller in 3D space and display the current FPS in 2D space (in the "mirror" window on your screen). So now you've got the cube running, but with these two nice helpers that let you see the controller and the FPS; and also, because Lodr is watching the files for changes, you can change "cube.lua" and save and any changes will pop on your VR headset in realtime. This is the way I develop my games.

In order for LoaderEnt to load a .lua file, the .lua file needs to return a Ent class, like the cube.lua example up there does. LoaderEnt can also load specially formatted .txt files, where each line is one path to something LoaderEnt knows how to load (a class .lua or txt file).

Ent lifecycle, continued

As above, when an insert()ed object becomes "live" (either lovr.load is called, or immediately on insert() if that's already happened), it gets an "onLoad" event. An object which has had its "onLoad" called has the ent.loaded field set.

As above, when you tell an ent to die(), it calls "onDie" on itself and its children, then remains in the tree until the end of the current frame. An object which has had its "onDie" called has the ent.dead field set.

When the die()d, it calls "onBury" on itself and its children, then removes itself from the tree. The garbage collector is now free to reclaim it.

It's nice to perform changes to the tree all at once so an object doesn't accidentally participate in only half of a frame. Toward that end, if you have an object you want to insert in the tree but only in the after-period at the end of a frame, you can use:

queueBirth( someConstructedEnt, someParent )

Or if you just have some general frame cleanup of some sort, you can use

queueDoom( someFunction )

And someFunction will be called during that same cleanup. Burying, birth and DOOM all occur in the order in which their respective die(), queueBirth() or queueDoom() got called.

By the way

Although lovr-ent is tied in pretty closely with LÖVR, there's nothing LÖVR-specific about the ent system itself. You could pull "engine/ent.lua" out and use it in a non-LÖVR project, and in fact ent.lua is just a rewrite of similar systems I've previously used in the Polycode and UFO LUA frameworks.

Using namespaces

If you want to understand the namespace feature, it has its documentation on a separate page.

But, the short version is: Normally in a Lua program every file has the same globals table. But if you put namespace "somename" at the top of your file, globals in that file will be shared only between other namespace "somename" files.

The way I recommend using this is, look for the "create a namespace for your game here" comment in main.lua. Delete that, insert namespace("mygamename", "standard"), and then assign any globals you want in your program. Then put namespace "mygamename" at the top of all your game's source files.

("standard" is the namespace that's used by lovr-ent itself. You want your namespace to inherit from "standard" so it's got all the lovr-ent stuff in it.)

lovr-ent globals

There's several files in lovr-ent which contain miscellaneous utilities and which are included into the "standard" namespace by default. These global symbols are listed below; you can find more detailed comments for some of them in the linked files:

In types.lua:

  • pull(dst, src) - copy all the fields from one object into another
  • tableInvert(t) - takes a table and returns a new table with keys and values swapped
  • tableConcat(a, b) - given two tables, return a new table with all the key/value pairs from both
  • tableSkim(a, keys) - given a table and a list of keys, return a new table picking only the key/value pairs whose keys are in the list
  • tableSkimUnpack(a, keys) - given a table and a list of keys, return the unpacked values corresponding to the requested keys in order
  • tableTrue(t) - true if table is nonempty
  • tableCount(t) - given a table returns the number of key/value pairs in it (including non-numeric keys)
  • toboolean(v) - converts value to true (if truthy) or false (if falsy)
  • ipairsReverse(t) - same as ipairs() but iterates keys in descending order
  • ichars(str) - like ipairs() but iterates over characters in a string
  • mapRange(count, f) - returns table mapping f over the integer range 1..count
  • classNamed(name, parent) - like calling Penlight class(), but sets the name
  • stringTag(str, tag) - Trivial function which returns "str-tag" if tag is non-nil and str if tag is nil.
  • A queue class
  • A stack class

In loc.lua:

  • "Loc", a rigid body transform class (see loc.lua for its functions or app/test/loc.lua for a demonstration). A Loc is a triplet of a position, rotation and scale. Locs can be composed and applied to vectors, like a mat4 (when applied to a vector the vector is scaled, then rotated, then translated).

In lovr.lua:

  • unpackPose(controllerName) - Given a controller name, returns a (vec3 at, quaternion rotation) pair describing its pose
  • offsetLine(at, q, offset) and forwardLine(at,q) - Takes the pair returned from unpackPose and either maps a vector into its pose's reference frame, or returns an arbitrary point the controller is "pointing at".
  • primaryTouched(controllerName), primaryDown(controllerName), primaryAxis(controllerName) - Equivalents of lovr.headset isTouched, isDown and axis but for whatever the appropriate "primary" thumb direction is on that device
  • Adds a loc:push() to Loc that lovr.graphics.push()es a Loc's transform

In ugly.lua:

  • ugly works exactly the same as the Penlight pretty class (it is a fork of pretty) but it shows only one layer of keys and values instead of recursing.

How to use modelView

If you launch lovr-ent with the argument app/debug/modelView, like:

lovr lovr-ent/lua app/debug/modelView

This will launch an app that searches the entire Lovr filesystem, lists all .gltf, .glb or .obj files it finds, and once you have selected one displays it, slowly rotating, with your choice of shaders.

Clicking the Standard... button will allow you to adjust the shader properties, and clicking Edit... will allow you to view or alter (but not save back) some properties of the animations, materials, and skeleton nodes in the model. Some additional information will be printed to STDOUT in these panes.

How to use the UI2 library

When you run LÖVR on a desktop computer, it displays a "mirror" window showing a copy of what's in the headset. There's a special callback, which in lovr-ent becomes the onMirror event, that lets you draw things just into this mirror window. I think this is a great place to draw 2D interfaces for debugging, level editor type things, etc.

Because the 2D UI parts of this library are intended for developer tools, not end user interfaces, they are all pretty simplistic.

Modes and "flat"

Normally, when onMirror gets called, the camera is still set up for 3D drawing. If you call

`uiMode()`

as the first line of your onMirror, it will set up a reasonable 2D orthographic camera (top of screen is y=1, bottom is y=-1, left side is -aspect and right side is +aspect where "aspect" is the window width divided by its height.

There's a convenient table in engine/flat.lua (see the comments in that file):

local flat = require "engine.flat"

...containing the metrics of the mirror window and a mirror-appropriate font.

UI2

Lovr-ent also comes with a file full of Ents that act as simple UI elements:

local ui2 = require "ent.ui2"

At the moment, it contains labels and buttons and there's an auto-layout class that sticks all the elements in the corner one after the other. This is mostly documented in the file, but the best way to understand it is to just read the example program. It's all hopefully obvious from the example.

UI2: The nonobvious parts

When you create a layout manager object, one of the allowed constructor parameters is pass=sometable. When the layout manager does layout, for each object it lays out, it will take every field in sometable and set those same fields on the table. If you want, in an Ent subclass you make, to do something with the passed parameters other than just setting them, overload layoutPass().

There's a class in ui2 named SwapEnt. This class adds one additional helper method to Ent, swap(otherEnt). This method causes the swap()ed ent to die(), then queue otherEnt for birth on the next frame. What is this for? Well, probably, if you're making debug/test UI screens, you won't have just one UI screen. You probably have several screens and some kind of top level main menu linking them all. So when you write the Ent that allocates and lays out all your ButtonEnts, have it inherit from SwapEnt, and then you can easily swap to another screen by creating it and calling Swap{} or just close by calling swap() with nil. (The modelView app is a good example of how to build a multiscreen application this way.)

Layout may take a constructor field named "mutable". Set this to true for layouts that you expect to change sometimes (IE, there is a button whose label might change after onLoad, changing the button's size). This field changes a few things: UiEnts in a mutable layout are allowed to have nil labels (though full layout will not occur while at least one UiEnt in the layout has a nil label); and UiEnts in a muable layer will be given a self:relayout() method which they should call on themselves when they know their size has changed. In both mutable and non-mutable layouts, you may call :layout(true) on a layout to force a re-layout of all buttons (potentially resizing in the process). There is an example of using this in my Lovr MIDI project.

Debug ents

The "apps" app/debug/hand and app/debug/fps, described above in the LoaderEnt doc, can also be required and inserted as child Ents in the onLoad of an Ent you define. In addition, there are a couple included ents which are nice for debug purposes:

Floor

ent/debug/floor draws a placeholder checkerboard floor. That's it. There's some simulated fog. You can set a physical size (floorSize) and a checkerboard density (floorPixels) in the constructor.

DebugCubes

ent/debug/floor is an Ent with fairly complex options (see comments in file) that draws temporary cubes. For example imagine your app declares self.debugCubes = (require "ent.debug.debugCubes")():insert(). You could then at some later point call:

self.debugCubes:add( vec3(2,1,1) )

This call will cause a cube to be drawn at coordinate (2, 1, 1) for the next 1 second. This can be useful for visualizing game logic that takes place in space; say you have 3D objects moving around, and when they collide you drop a debug cube at the collision point.

Instead of a vector you can give it a table describing specific properties of the cube:

self.debugCubes:add( {at=vec3(2,1,1), color={1,0,0}, lineTo=vec3(1,0,0), lineColor={0,0,0}}, 0.5, true )

This will draw a red cube at (2, 1, 1), with a black line from its center to (1, 0, 0). The second argument causes the draw time to be half a second instead of the default 1. Passing true for the third argument causes the cube and line to be drawn "on top" of everything else (ie with depth test off).

The exact keys accepted in the cube table are described in the comment in the source file, but especially noteworthy options are:

  • Passing false for the duration causes the cube to never expire (draw forever)
  • Passing true for the duration causes the cube to draw for one frame and then expire immediately (useful if, for exmaple, you call this add() in your onUpdate every frame)
  • Passing a noCube=true key in your cube description table causes it to draw no cube, only a line.

Setting onTop=true in the DebugCubes constructor causes the cubes to always be drawn on top regardles of the third arguemnt.

Thread helpers

The engine/thread directory contains helpers for writing code that use Lovr threads. There is a flag local singleThread = false at the top of main.lua; set this to true and the included thread tools (well, thread tool; see "Loader" below) will degrade gracefully to a single-threaded mode.

I don't yet have documentation for the thread classes, however, there is a sample program app.test.thread; see the comments on the functions in the app/test/thread directory to see how doing threads the lovr-ent way works.

Loader

The one finished thread utility is a loader class. require engine.thread.loader and call Loader() to create a loader object:

self.textureData = Loader("textureData", "path/to/an/image.png")

The moment this loader object is constructed, it will start loading image.png from disk and decoding it into a Lovr TextureData object. Later, when you need to use the TextureData object, call self.textureData:get(); if the TextureData has finished loading it will return it, otherwise it will block until loading finishes and then return it. The first argument determines what kind of data to load; the two currently recognized keys are "modelData" and "textureData". If you want to add more load types, edit engine/thread/action/loader.lua.

There are optional third and fourth arguments to the Loader constructor. The third argument is a "filter" function which is executed on the main thread on the loaded value as soon as the value is received from the helper thread; the fourth is the name of the loader thread to use (there can be more than one at once). So for example you could say:

self.texture = Loader("textureData", "path/to/texture.png", Loader.dataToTexture) 
self.model = Loader("modelData", "model/RemoteControl_L.glb", Loader.dataToModel, "model")

You probably don't want a TextureData or a ModelData; you want a Texture or a Model. Unfortunately right now in Lovr Textures and Models can only be created on the main thread, so the built-in Loader.dataToTexture and Loader.dataToModel filters do that conversion for you so when you later call self.texture:get() you know it will return a texture. In this example the texture is loaded on the default loader thread and the model is loaded on a second loader thread identified by the key "model".

As a very minor optimization, there's a connect method on the Loader class which can be used to kick off loader threads before you actually construct any Loader objects. For example I like to include the Loader class like this:

local Loader = require "engine.thread.loader"
Loader:connect()
Loader:connect("model")

The loader threads take a little bit of time to run their init code, so calling connect early means that init code will start as soon as you've required loader.lua instead of waiting until you first initialize a Loader object.

About

Entity tree library / sample project for the LOVR vr game engine

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • Lua 100.0%