Skip to content

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.