Wake Engine Update #1

I’ve been putting in some more work recently on Wake engine. Some of the new notable features are support for loading models, a custom model format optimized for the engine (plus tools to work with the format), keyboard/mouse input, and a whole bunch of other stuff. The engine has come a long way since last time, though it still has a long way to go.

WMDL

The first thing I’d like to talk about is the new model format, WMDL. While Wake supports loading most common model formats (via assimp), this generally involves copying a lot of data around. My solution is to create a format that mimics Wake’s Model class, so that the loader can read the model directly into an object the engine can use without doing any additional transformations or copies.

The format is very simple due to it mimicking the in-memory layout of the Model class. It also supports compression (via snappy) which in many cases actually speeds up model loading as performance is mostly contingent on disk IO, so smaller files = less time spent waiting for the disk. Each WMDL model can contain a list of materials, defined by a base material and list of parameters (see below), and a list of meshes, defined by a list of vertices, normals, and indices.

Materials

There is now a basic material system present in the engine. Materials are objects of the Material class. A material contains a single shader which instructs the graphics card how to render it along with a list of default parameters and textures. Materials can be copied in order to modify parameters without affecting the base material. Additionally, materials generally have a type name which is used in order to reference the material from WMDL files.

A material can be created on the fly with Material.new(), but generally you want to define a lua file which sets up a material and returns it. Here’s the source for the materials.demo_lighting material as an example.

-- First we need to create the shader
local shader = Shader.new(
[[
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;
layout (location = 2) in vec2 texCoords;

uniform mat4 projection;
uniform mat4 view;
uniform mat4 transform;

out vec3 outNormal;
out vec2 outTexCoords;

void main()
{
    gl_Position = projection * view * transform * vec4(position, 1.0);
    outNormal = normal;
    outTexCoords = texCoords;
}
]],
[[
#version 330 core
in vec3 outNormal;
in vec2 outTexCoords;

out vec4 outColor;

uniform sampler2D tex1;
uniform vec3 lightColor;
uniform vec3 lightDirection;
uniform float lightAmbience;
uniform float minBrightness;

void main()
{
    vec4 texColor = texture(tex1, outTexCoords);

    float diffuseIntensity = max(minBrightness, dot(normalize(outNormal), -normalize(lightDirection)));
    outColor = vec4(lightColor, 1.0) * vec4(lightColor * (lightAmbience * diffuseIntensity) * texColor.rgb, 1.0);
}
]]
)

-- Next, we create and set default parameters on the material
local material = Material.new()
material:setShader(shader)
material:setTypeName("materials.demo_lighting")
material:setVec3('lightColor', {1, 1, 1})
material:setVec3('lightDirection', {1, -1, 0.6})
material:setFloat('lightAmbience', 0.8)
material:setFloat('minBrightness', 0.15)
material:setMatrix4('projection', Matrix4x4.new())
material:setMatrix4('view', Matrix4x4.new())
material:setMatrix4('transform', Matrix4x4.new())

-- Finally, return the material from the file
return material

By making each material its own file, the material can be retrieved by simply require-ing the file. A utility function, assets.loadMaterials, is provided which will actually do just that. You pass in a model that was loaded from disk and it will automatically load the materials referenced by it and copy them into the model.

A Simple Demo

There’s actually a lot of other new stuff in Wake (a simple Camera class implemented in lua as an example, global materials, a lua-based event system to complement native events, etc…), so here’s a simple demo that shows off some of the new features.

-- Import some files
local Camera = require('camera')
local config = require('config.cfg') -- this is used for setting mouse sensitivity later

-- Load the Sponza Atrium model, in wmdl format. The WMDL was created by running the wmdl tool on sponza.obj: "wake -x wmdl assets/models/sponza.obj assets/models/sponza.wmdl"
-- The WMDL was further modified to use the demo_lighting material with the modify-model tool.
local model = assets.loadModel('assets/models/sponza.wmdl')
if obj == nil then
  print('Unable to load model.')
  return
end

-- Load materials into the model. Models that have been loaded with assets.loadModel will have placeholder materials that don't do anything,
-- so we need to tell the engine to load the actual materials.
assets.loadMaterials(model)

-- Set the background color of the window to white
engine.setClearColor(1, 1, 1, 1)

-- here are the variables for our camera
local cam = Camera.new(Vector3.new(-2.5, 0, 0)) -- we pass the initial position for the camera
local speed = 1 -- the normal movement speed for the camera
local fastSpeed = 2 -- the fast movement speed for the camera

-- every "tick" of the main loop, this event is called
engine.tick:bind(function(dt)
  local moveSpeed = speed
  -- check if we want to move fast
  if input.getKey(input.key.LeftShift) == input.action.Press then
    moveSpeed = fastSpeed
  end

  -- Movement for WASD plus down/up with Q/E
  if input.getKey(input.key.W) == input.action.Press then
      cam:moveForward(moveSpeed * dt)
  end

  if input.getKey(input.key.S) == input.action.Press then
      cam:moveForward(-moveSpeed * dt)
  end

  if input.getKey(input.key.A) == input.action.Press then
      cam:moveRight(-moveSpeed * dt)
  end

  if input.getKey(input.key.D) == input.action.Press then
      cam:moveRight(moveSpeed * dt)
  end

  if input.getKey(input.key.Q) == input.action.Press then
      cam:moveUp(-moveSpeed * dt)
  end

  if input.getKey(input.key.E) == input.action.Press then
      cam:moveUp(moveSpeed * dt)
  end

  -- The current way to pass parameters to a model to use for drawing is to create an empty material and
  -- assign parameters. These parameters will be passed to the materials that the model uses. This will likely
  -- be changed out for a separate parameter system at some point.

  -- Here we use this to pass a scaling operation as the model's transform, as the model is way too big.
  local params = Material.new()
  params:setMatrix4("transform", math.scale{0.002, 0.002, 0.002}) -- note the curly brackets {} instead of parenthesis () for math.scale, since we are passing a single Vector3 as opposed to 3 arguments.
  
  -- the sample camera class stores the view and projection matrix in a material by "using" the camera
  cam:use(params)

  -- finally, draw the model
  model:draw(params)
end)

-- We use the key event in order to stop the engine if the user presses escape
input.event.key:bind(function(key, action)
  if key == input.key.Escape and action == input.action.Release then
    engine.stop()
  end
end)

-- This is the code used to implement mouse-look for the camera
local lastX = 0
local lastY = 0
local firstMouse = true -- we use this variable so we don't suddenly jump the camera view on the first frame

input.setCursorMode(input.cursorMode.Disabled) -- this hides the cursor and locks it to the center of the screen

-- this event is called whenever the cursor is moved
input.event.cursorPos:bind(function(x, y)
  if firstMouse then
    firstMouse = false
    lastX = x
    lastY = y
  end

  -- a bit of math to figure out how much to rotate, based on the mouseSensitivity variable in the engine config
  local xOffset = (x - lastX) * config.input.mouseSensitivity
  local yOffset = (y - lastY) * config.input.mouseSensitivity

  -- add the rotation to the camera's rotation
  cam:addRotation(Vector3.new(0, xOffset, yOffset))

  lastX = x
  lastY = y
end)

Leave a Reply