Are 3D dice in a 2D game possible in defold?

See this game for an example. I’m making 2D game but I need 3D dice. And I want the dice to “feel good” as they do in the game slice & dice.

See this video for an example:
https://www.youtube.com/watch?v=k5UBfPwqp7s&t=4s

Is this possible in defold without going full 3D? If so, how?

This is less of a problem of figuring out how to achieve the desired effect in Defold, and more a problem of how to achieve it in general. How can we rotate an object on all three axes using only two axes? Here are some ideas, the first one being the most obvious to me:

  • Use 3D anyway. The entirety of the game can be in 2D, while the dice can be in 3D. To do this, you can render the dice separately from everything else using both a 2D camera and a 3D camera.
  • Spin the dice around the z axis since that’s the only axis that feels natural to spin around in a 2D world, and occasionally update the dice’s animation frame relative to its spin rate.
  • Create 2D animation frames that are pre-rendered from a 3D object. The dice will have a 3D style, but in reality it is still only a four-vertex sprite.

I looked up the game you referenced and found that they indeed rendered the dice separately from the rest of the game, meaning it’s not an entirely 2D scene you’re looking at when you play the game. I’m sure there are other creative ways of simulating a roll effect that feels good, but that’ll have to be something to experiment with. :slight_smile:

Thank you. I’ve already done 2d animations based on a 3D object and unfortunately it doesn’t feel right and enough rolls you quickly see behind the curtain.

But I think I’ll try that dual camera method. I’ve never done anything like that in defold, so it should be interesting. Time to start learning how to do multiple cameras :slight_smile:

The main thing you need to know when dealing with multiple cameras is that you must specify which render predicates to draw per camera in your render script. So for example, maybe you have a 2D camera that draws all predicates except one, then a 3D camera that draws that last predicate which draws the dice. An extension like Rendy supports multiple cameras out of the box, however you’ll still have to edit its render script to differentiate between the predicates, as I mentioned earlier.

1 Like

Actually in Slice&Dice game (very cool roguelike) doesn’t even use perspective camera for 3d dice, there is usual orthographic projection, you just need to increase near far range of the camera and start rotating the dice (it can be a model, mesh or even just a set of 6 sprites) on different axes + dice toss animation and you will get the same result.

S&D:
2024-05-21_15-12-21

Defold example:

5 Likes

Thank you. Where did you find that defold example?

Thanks for the suggestion. I nearly have it “working”. But as you can see below I have something wrong as it won’t display the front face of the die. I’ve tried enabling and disabling culling…same thing every time.

Once I get this working, I’ll share the die on Github so others can learn from this and just download and update as needed.

See IDE looks correct:

Runtime though is broken:

This is my dice script:

-- dice.script

function init(self)
	-- Initial position and rotation
	self.initial_position = go.get_position()
	self.initial_rotation = go.get_rotation()

	-- Set initial position and rotation
	go.set_position(self.initial_position)
	go.set_rotation(self.initial_rotation)

	-- Random seed for dice rolls
	math.randomseed(os.time())

	print("Dice initialized")
end

function roll_dice(self)
	-- Define new random rotation for the roll
	local roll_duration = math.random() + 1  -- Random duration between 1 and 2 seconds
	local end_rotation = vmath.quat_rotation_x(math.rad(math.random(360))) *
	vmath.quat_rotation_y(math.rad(math.random(360))) *
	vmath.quat_rotation_z(math.rad(math.random(360)))

	-- Animate rotation
	go.animate(".", "rotation", go.PLAYBACK_ONCE_FORWARD, end_rotation, go.EASING_OUTBOUNCE, roll_duration, 0, function()
		-- Animation completed callback
		-- Here you can set the final face of the dice if needed
		print("Dice roll complete")
	end)
end

function on_message(self, message_id, message, sender)
	if message_id == hash("roll_dice") then
		print("Roll dice message received")
		roll_dice(self)
	end
end

This is my render script:

-- Copyright 2020-2024 The Defold Foundation
-- 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 = -10
local DEFAULT_FAR = 10
local DEFAULT_ZOOM = 1

--
-- 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

local function update_camera(camera, state)
    camera.proj = camera.projection_fn(camera, state)
    camera.frustum.frustum = camera.proj * camera.view
end

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
    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

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

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 = {}
    return state
end

function init(self)
    self.predicates = create_predicates("tile", "gui", "particle", "model", "debug_text")

    -- 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
    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

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)

    -- setup camera view and projection
    --
    local camera_world = state.cameras.camera_world
    render.set_viewport(0, 0, state.window_width, state.window_height)
    render.set_view(camera_world.view)
    render.set_projection(camera_world.proj)

    -- Enable depth testing for 3D models
    render.enable_state(render.STATE_DEPTH_TEST)
    render.set_depth_mask(true)

    -- Disable face culling to ensure all faces are rendered
    render.disable_state(render.STATE_CULL_FACE)

    print("Rendering 3D models")
    render.draw(predicates.model, camera_world.frustum)

    -- Disable depth testing after drawing 3D models
    render.disable_state(render.STATE_DEPTH_TEST)
    render.set_depth_mask(false)

    -- Enable blending for 2D elements
    render.enable_state(render.STATE_BLEND)
    render.set_blend_func(render.BLEND_SRC_ALPHA, render.BLEND_ONE_MINUS_SRC_ALPHA)

    -- Render other components: sprites, tilemaps, particles, etc.
    render.draw(predicates.tile, camera_world.frustum)
    render.draw(predicates.particle, camera_world.frustum)

    -- Render debug 3D elements
    render.draw_debug3d()

    -- Render GUI
    local camera_gui = state.cameras.camera_gui
    render.set_view(camera_gui.view)
    render.set_projection(camera_gui.proj)

    render.enable_state(render.STATE_STENCIL_TEST)
    render.draw(predicates.gui, camera_gui.frustum)
    render.draw(predicates.debug_text, camera_gui.frustum)
    render.disable_state(render.STATE_STENCIL_TEST)
    render.disable_state(render.STATE_BLEND)
end

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

Since the front and back faces are cut off, this is probably an issue with your near and far clipping planes. For example, are they set to [-1, 1]? If so, check the size and position of each of the faces of your die. If they exceed the [-1, 1] z range, then you can either increase that range or shrink the die such that it fits entirely within that range.

Thanks. That did it, now I just need to get the die to roll without it looking like it’s coming apart. LOL

I think you want to use the euler property for rotation instead of rotation here, since that’s a quaternion which is a form of black magic that tends to transform objects in weird ways.

1 Like

YES! That was it. Thank you so much. Now I will work on just making it land more upright (should be easy enough).

3 Likes

I ended up going 3D physics as the dice “feel better”. Just need a top down camera and don’t move it…this gives it a 2D look with 3D dice.

If anyone else is trying to do something similar I highly recommend this approach. Defold’s 3D engine and APIs are solid for something like this. I don’t need lights Just adjusting the ambient light in the shader model lit up the whole scene.

9 Likes

Congratulations finishing this feature, looking good!

1 Like

Good job!

1 Like