My Complete Defold Render Script
If you're planning to modify the render script yourself, expect to find only partial examples online. You’ll need to combine techniques from various scripts and spend time with the render pipeline manual and the render API. This process involves a lot of trial and error, so be prepared to dig into the code and make sense of it.
Here’s my consolidated Defold render script—a mix between the example script from this light map tutorial by UnfoldingGamedev and the default render script.
While I didn’t write most of this code, I’ll indicate the parts that I did, and otherwise how I got here.
Code
License and Constants
I just mixed the constants from both scripts.
-- Copyright 2020-2024 The Defold Foundation
-- Copyright 2014-2020 King
-- Copyright 2009-2014 Ragnar Svensson, Christian Murray
-- Licensed under the Defold License version 1.0 (the "License"); you may not use
-- this file except in compliance with the License.
--
-- You may obtain a copy of the License, together with FAQs at
-- https://www.defold.com/license
--
-- Unless required by applicable law or agreed to in writing, software distributed
-- under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
-- CONDITIONS OF ANY KIND, either express or implied. See the License for the
-- specific language governing permissions and limitations under the License.
--
-- message constants
--
local MSG_CLEAR_COLOR = hash("clear_color")
local MSG_WINDOW_RESIZED = hash("window_resized")
local MSG_SET_VIEW_PROJ = hash("set_view_projection")
local MSG_SET_CAMERA_PROJ = hash("use_camera_projection")
local MSG_USE_STRETCH_PROJ = hash("use_stretch_projection")
local MSG_USE_FIXED_PROJ = hash("use_fixed_projection")
local MSG_USE_FIXED_FIT_PROJ = hash("use_fixed_fit_projection")
local DEFAULT_NEAR = -1
local DEFAULT_FAR = 1
local DEFAULT_ZOOM = 1
local IDENTITY_MATRIX = vmath.matrix4()
Projection Functions
You can probably remove them if you don't need them but here they are.
--
-- projection that centers content with maintained aspect ratio and optional zoom
--
local function get_fixed_projection(camera, state)
camera.zoom = camera.zoom or DEFAULT_ZOOM
local projected_width = state.window_width / camera.zoom
local projected_height = state.window_height / camera.zoom
local left = -(projected_width - state.width) / 2
local bottom = -(projected_height - state.height) / 2
local right = left + projected_width
local top = bottom + projected_height
return vmath.matrix4_orthographic(left, right, bottom, top, camera.near, camera.far)
end
--
-- projection that centers and fits content with maintained aspect ratio
--
local function get_fixed_fit_projection(camera, state)
camera.zoom = math.min(state.window_width / state.width, state.window_height / state.height)
return get_fixed_projection(camera, state)
end
--
-- projection that stretches content
--
local function get_stretch_projection(camera, state)
return vmath.matrix4_orthographic(0, state.width, 0, state.height, camera.near, camera.far)
end
--
-- projection for gui
--
local function get_gui_projection(camera, state)
return vmath.matrix4_orthographic(0, state.window_width, 0, state.window_height, camera.near, camera.far)
end
local function update_clear_color(state, color)
if color then
state.clear_buffers[render.BUFFER_COLOR_BIT] = color
end
end
Draw to Render Target Helper Functions
The original version of these functions is by the tutorial author. I'm going to assume you watched that video or otherwise read the example code from here on out.
local function render_to_render_target(render_target, draw_function)
render.set_render_target(render_target)
draw_function()
render.set_render_target(render.RENDER_TARGET_DEFAULT)
end
local function clear_with_color(state, clear_color)
render.set_depth_mask(true)
render.set_stencil_mask(0xff)
local old_color = state.clear_buffers[render.BUFFER_COLOR_BIT]
if clear_color ~= nil then
state.clear_buffers[render.BUFFER_COLOR_BIT] = clear_color
end
render.clear(state.clear_buffers)
state.clear_buffers[render.BUFFER_COLOR_BIT] = old_color
end
local function set_default_renderer_state()
render.set_depth_mask(false)
render.disable_state(render.STATE_DEPTH_TEST)
render.disable_state(render.STATE_STENCIL_TEST)
render.enable_state(render.STATE_BLEND)
render.set_blend_func(render.BLEND_SRC_ALPHA, render.BLEND_ONE_MINUS_SRC_ALPHA)
render.disable_state(render.STATE_CULL_FACE)
end
The main difference here is clear_with_color
, which is now passed state
.
This is going to be a running theme which will make more sense soon.
The Very Intimidating Functions
They're not that bad once you have some idea what they do, but at first you don't even know where to begin.
We'll start with the simplest one.
local function update_camera(camera, state)
camera.proj = camera.projection_fn(camera, state)
camera.frustum.frustum = camera.proj * camera.view
end
Notice it's camera.frustum.frustum
and not camera.frustum
.
I could have probably renamed it but I didn't.
local function update_state(state)
state.window_width = render.get_window_width()
state.window_height = render.get_window_height()
state.valid = state.window_width > 0 and state.window_height > 0
if not state.valid then
return false
end
-- Make sure state updated only once when resize window
if state.window_width == state.prev_window_width and state.window_height == state.prev_window_height then
return true
end
state.prev_window_width = state.window_width
state.prev_window_height = state.window_height
-- nobody wrote this block :)
for _, render_target in pairs(state.render_targets) do
render.set_render_target_size(render_target, state.window_width, state.window_height)
end
state.width = render.get_width()
state.height = render.get_height()
for _, camera in pairs(state.cameras) do
update_camera(camera, state)
end
return true
end
The targets are surfaces of a fixed size. Unless you manually handle resizing them, your window resizing will be broken. If I had known this when I started, I probably would not have been stuck for so long. This is where reading the API functions led me to the answer.
local function init_camera(camera, projection_fn, near, far, zoom)
camera.view = vmath.matrix4()
camera.near = near == nil and DEFAULT_NEAR or near
camera.far = far == nil and DEFAULT_FAR or far
camera.zoom = zoom == nil and DEFAULT_ZOOM or zoom
camera.projection_fn = projection_fn
end
local function create_predicates(...)
local arg = {...}
local predicates = {}
for _, predicate_name in pairs(arg) do
predicates[predicate_name] = render.predicate({predicate_name})
end
return predicates
end
local function create_camera(state, name, is_main_camera)
local camera = {}
camera.frustum = {}
state.cameras[name] = camera
if is_main_camera then
state.main_camera = camera
end
return camera
end
All unchanged.
local function create_state()
local state = {}
local color = vmath.vector4(0, 0, 0, 0)
color.x = sys.get_config_number("render.clear_color_red", 0)
color.y = sys.get_config_number("render.clear_color_green", 0)
color.z = sys.get_config_number("render.clear_color_blue", 0)
color.w = sys.get_config_number("render.clear_color_alpha", 0)
state.clear_buffers = {
[render.BUFFER_COLOR_BIT] = color,
[render.BUFFER_DEPTH_BIT] = 1,
[render.BUFFER_STENCIL_BIT] = 0
}
state.cameras = {}
state.render_targets = {}
local color_params = {
format = render.FORMAT_RGBA,
width = render.get_window_width(),
height = render.get_window_height(),
min_filter = render.FILTER_LINEAR,
mag_filter = render.FILTER_LINEAR,
u_wrap = render.WRAP_CLAMP_TO_EDGE,
v_wrap = render.WRAP_CLAMP_TO_EDGE
}
local parameters = {
[render.BUFFER_COLOR_BIT] = color_params
}
state.render_targets.world = render.render_target("world", parameters)
state.render_targets.lights = render.render_target("lights", parameters)
return state
end
The render targets are defined and added to a table here instead of in init
. This is because we want to be able to access and modify them from state
, which is passed around in many different functions without self
. We could have modified this of course. But why not continue this pattern, reinforcing that the render targets are stateful.
Much Simpler init
Function
function init(self)
self.predicates = create_predicates("tile", "gui", "particle", "model", "debug_text", "light", "quad")
-- default is stretch projection. copy from builtins and change for different projection
-- or send a message to the render script to change projection:
-- msg.post("@render:", "use_stretch_projection", { near = -1, far = 1 })
-- msg.post("@render:", "use_fixed_projection", { near = -1, far = 1, zoom = 2 })
-- msg.post("@render:", "use_fixed_fit_projection", { near = -1, far = 1 })
local state = create_state()
self.state = state
-- Set up cameras
local camera_world = create_camera(state, "camera_world", true)
init_camera(camera_world, get_stretch_projection)
local camera_gui = create_camera(state, "camera_gui")
init_camera(camera_gui, get_gui_projection)
update_state(state)
end
I was originally trying to cram everything in here, which is fine if you want to simplify. However at the time, I was hesitant to remove all the code from the local functions above. I wasn't sure what they did or what features they allowed or what I would sacrifice by removing them or how to get any ideas I had inside of them.
It takes time just going over every one.
Stateful update
Function
The main difference is we are accessing a lot of values from state now. Big surprise!
function update(self)
local state = self.state
if not state.valid then
if not update_state(state) then
return
end
end
local predicates = self.predicates
-- clear screen buffers
--
-- turn on depth_mask before `render.clear()` to clear it as well
render.set_depth_mask(true)
render.set_stencil_mask(0xff)
render.clear(state.clear_buffers)
-- Draw to world render target
render_to_render_target(state.render_targets.world, function()
clear_with_color(state)
render.set_viewport(0, 0, render.get_window_width(), render.get_window_height())
render.set_view(state.cameras.camera_world.view)
render.set_projection(state.cameras.camera_world.proj)
set_default_renderer_state()
render.draw(predicates.tile, state.cameras.camera_world.frustum)
render.draw(predicates.particle, state.cameras.camera_world.frustum)
render.draw_debug3d()
end)
-- Draw to lights render target
render_to_render_target(state.render_targets.lights, function()
clear_with_color(state, self.ambient_color or vmath.vector4(0.05, 0.1, 0.3, 1))
render.set_viewport(0, 0, render.get_window_width(), render.get_window_height())
render.set_view(state.cameras.camera_world.view)
render.set_projection(state.cameras.camera_world.proj)
set_default_renderer_state()
render.draw(predicates.light, state.cameras.camera_world.frustum)
end)
-- Render world and lights to quad
render.set_view(IDENTITY_MATRIX)
render.set_projection(IDENTITY_MATRIX)
set_default_renderer_state()
render.enable_material(hash("world_quad"))
render.enable_texture(0, state.render_targets.world, render.BUFFER_COLOR0_BIT)
render.enable_texture(1, state.render_targets.lights, render.BUFFER_COLOR0_BIT)
render.draw(predicates.quad)
render.disable_texture(0)
render.disable_texture(1)
render.disable_material()
-- Render GUI
render.set_view(state.cameras.camera_gui.view)
render.set_projection(state.cameras.camera_gui.proj)
render.enable_state(render.STATE_STENCIL_TEST)
render.draw(predicates.gui, state.cameras.camera_gui.frustum)
render.draw(predicates.debug_text, state.cameras.camera_gui.frustum)
render.disable_state(render.STATE_STENCIL_TEST)
render.disable_state(render.STATE_BLEND)
end
The one part I added was render.set_viewport(0, 0, render.get_window_width(), render.get_window_height())
which is something I tried while reading the docs and looking for something that would work.
It got the game to render instead of the black screen so I stuck with it.
Familiar on_message
Function
Like the projection functions, you can probably remove the ones you don't need.
Like everything else ... yeah, state
.
function on_message(self, message_id, message)
local state = self.state
local camera = state.main_camera
if message_id == MSG_CLEAR_COLOR then
update_clear_color(state, message.color)
elseif message_id == MSG_WINDOW_RESIZED then
update_state(state)
elseif message_id == MSG_SET_VIEW_PROJ then
camera.view = message.view
self.camera_projection = message.projection or vmath.matrix4()
update_camera(camera, state)
elseif message_id == MSG_SET_CAMERA_PROJ then
camera.projection_fn = function() return self.camera_projection end
elseif message_id == MSG_USE_STRETCH_PROJ then
init_camera(camera, get_stretch_projection, message.near, message.far)
update_camera(camera, state)
elseif message_id == MSG_USE_FIXED_PROJ then
init_camera(camera, get_fixed_projection, message.near, message.far, message.zoom)
update_camera(camera, state)
elseif message_id == MSG_USE_FIXED_FIT_PROJ then
init_camera(camera, get_fixed_fit_projection, message.near, message.far)
update_camera(camera, state)
end
end
Who Is This For?
Only two types of people have read this far.
Someone Who Has No Idea What They Are Doing
You and I are in the same boat. I might have figured out enough to get this far, but these are deep waters, and I have a feeling its not over for either one of us.
Hopefully you can drop this in and solve the post-tutorial problems you still had. :P
Someone Very Familiar With This Material
If you have any suggestions for how I can improve this, let me know.
Conclusion
If I had known that I only needed to resize the render targets and everything would be fine, I'd have saved myself a lot of trouble just adding that to the UnfoldingGamedev example. Instead I thought there was something in this code that was making the resize work as expected.
After getting it all lined up, the solution ended up being something I had to find myself anyway. Luckily all the functions for managing state were there to help me manage it.
And I know a little bit more about how rendering works.