2023-12-26

Project Raygine #7

I am about to delete the last remaining bits of the initial scene and asset system. Now that I have the last remains at hand, it's a good moment to compare how it was and how it is now.

At the surface, it doesn't look much different. Here's the old version:

The old Raygine version

And here is the new version:

The new Raygine version

The code however looks very different. The last bit that remains (which was also the newest part of the old system) looks like this (shortening a few things):

local component_utils = require "component.component_utils"

---@class primitive_renderer_component_class:scene_component_class
local primitive_renderer_component_class = {
    name = "Primitive renderer",
    class_uid = "primitive_renderer_component",
    allow_multiple = true,
    ---@type attribute_info[]
    attributes = {
        {
            name = "color",
            type = "color",
            default = { 1, .75, .5, 1 }
        },
        {
            name = "shape",
            type = "string",
            default = "cube",
            options = {
                "cube",
                "sphere",
                "cylinder",
                "cone",
                "capsule",
                "quad",
            }
        },
        {
            name = "wireframe",
            type = "boolean",
            default = false
        },
        {
            condition = function(self)
                return self.shape == "cube"
            end,
            name = "cube_size",
            type = "vector3",
            default = { 1, 1, 1 }
        },
-- ...
    },
}


local primitive_renderer_component

function primitive_renderer_component_class:new(class_uid, instance_uid, data)
    return primitive_renderer_component:new(class_uid, instance_uid, data)
end

primitive_renderer_component = {}
primitive_renderer_component._mt = {
    __index = primitive_renderer_component
}

function primitive_renderer_component:new(class_uid, instance_uid, data)
    local o = setmetatable({}, self._mt)
    o.class_uid = class_uid
    o.uid = instance_uid
    o.wireframe = data.wireframe
    o.shape = data.shape
    o.color = data.color
    o.cube_size = data.cube_size
    o.sphere_radius = data.sphere_radius
    o.cylinder = data.cylinder or {
        radius = 1,
        height = 1,
        slices = 16
    }
    return o
end

function primitive_renderer_component:serialize()
    return {
        shape = self.shape,
        color = self.color,
        cube_size = self.cube_size,
        sphere_radius = self.sphere_radius,
        cylinder = self.cylinder,
        wireframe = self.wireframe
    }
end

function primitive_renderer_component:on_draw(entity)
    local color = self.color or { 1, 1, 1, 1 }
    local rgba = rl_color.new(color[1] * 255, color[2] * 255, color[3] * 255, color[4] * 255)
    local m = component_utils:get_world_matrix(entity)
    rl_gl.push_matrix()
    rl_gl.multiply_matrix(m)
    local draw_fn = self.draw_shape[self.shape]
    if draw_fn then
        draw_fn(self, entity, rgba)
    end
    rl_gl.pop_matrix()
end

primitive_renderer_component.draw_shape = {}

function primitive_renderer_component.draw_shape.cube(component, entity, rgba)
    local pos = rl_vector3.new(0, 0, 0)
    local size_tab = component.cube_size or { 1, 1, 1 }
    local size = rl_vector3.new(unpack(size_tab))

    rl_model.draw_cube(pos, size, rgba, component.wireframe)
end

-- ...

In total, it's over 160 lines of code. I tried leaving out some larger parts, but it's not that easy because the code is interwined.

The new version is not so much shorter (110 loc, so ~30 less), but it's much more readable and maintainable - it was also easier to leave away parts that aren't necessary for comparison. For comparison:

local T = require "type.type_identifiers"
local type_registry = require "type.type_registry"

---@class raygine.primitive_type.base : object
---@field is_wire boolean
local primitive_type_base =
    type_registry:define(T.raygine.primitive_type.base):set_is_abstract(true)
    :add_attribute("is_wire", T.boolean, false)
    :get_class()

function primitive_type_base:draw(mesh_primitive)
    Print_once("draw primitive for " .. self.__type .. " of " .. mesh_primitive.entity.__uid .. " not defined")
end

---@class raygine.primitive_type.cube : raygine.primitive_type.base
---@field size number[]
local primitive_type_cube = type_registry
    :define(T.raygine.primitive_type.cube):set_super(T.raygine.primitive_type.base)
    :set_display_name("Cube")
    :set_display_order(1)
    :add_attribute("size", T.vector3, { 1, 1, 1 })
    :get_class()

function primitive_type_cube:draw(mesh_primitive)
    local pos = rl_vector3.new(0, 0, 0)
    local size_tab = self.size or { 1, 1, 1 }
    local size = rl_vector3.new(unpack(size_tab))
    local rgba = mesh_primitive.material and mesh_primitive.material.color:to_int() or 0xff00ffff
    rl_model.draw_cube(pos, size, rgba, self.is_wire)
end
--...

---@class raygine.mesh_primitive_component : scene_component
---@field material raygine.material
---@field configuration raygine.primitive_type.base
local mesh_primitive_component = type_registry
    :define(T.raygine.mesh_primitive_component):set_super(T.raygine.scene_component)
    :set_display_name("Mesh primitive")
    :add_reference("material", T.raygine.material)
    :add_attribute("configuration", T.raygine.primitive_type.base):set_is_polymorphic(true):set_is_not_foldable()
    :get_class()

function mesh_primitive_component:on_draw()
    if self.configuration == nil then return end
    local t = self.entity:get_component(T.raygine.transform_component) or
        self.entity:get_component_in_parent(T.raygine.transform_component)
    ---@cast t transform_component
    local world_matrix = t and t.world_matrix or rl_matrix.new()
    rl_gl.push_matrix()
    rl_gl.multiply_matrix(world_matrix)
    self.configuration:draw(self)
    rl_gl.pop_matrix()
end

The new sample shows even includes code annotations for variable types. This is the last bit of redundancy - before that, the old version had 3 or 4 places where names got repeated. And I am already thinking about generating the code annotations from the type definitions - though I don't know yet how to include descriptions.

What is more important to me is, that the individual parts are now more separated and easier to understand since they focus on one particular thing. The serialization part is completely gone, even though the new system handles more different cases - like the polymorphic types:

Polymorphic type selection in the inspector

Here I can select the type that is to be used in place of the configuration. In the old system, I used lambdas for conditional checks if the attribute should be displayed in the editor based on the current configuration. This was a bit of a hack and I am glad I could get rid of it.

Another thing that works now which was only working in a hacky way before is the referencing of other assets, such as materials. This is now fully functional: I can select a material asset and it will be referenced by the mesh primitive component through it's uid.

I still have quite a lot of cleanups to do; deleting old code, splitting files into smaller ones, renaming classes to use namespaces.

After I am done with this, I will merge my branch into my main again and pick up the previous task I had started: Working out the runtime environment. I have now the project settings that references the main scene asset, so the starting point is now clear.

What is more problematic is my recent realization that some code works different at runtime than at edit time; when I change something through the inspector, I am right now saving the change automatically after a second without change. Deletions work the same (I don't spend time right now on undo/redo, but I will have to do that at some point). At runtime, I won't be changing assets. In fact, certain assets must be reloaded from disk, for instance when loading a scene. I believe I can work around this issue by making the runtime work readonly on assets without changing much, but still, I only discovered that I need to handle this during the last days. There's probably more.

The good part about my plan how to implement the runtime is, that it'll run in a separate Lua VM. There's no shared memory between the editor and the runtime, even when running in the same process. But I first have to finish the cleanups before I get involved into runtime stuff.

PS: I am reviewing the pull request and I am adding ~2400 lines of code and removing ~1900 lines of code - so in total, it's 500 lines more, which isn't much considering that these are partially comments, changes to the scene format and the new type system that has some fancy new features like automatic serialization and deserialization, polymorphic data types, asset finding and a cleaner UI rendering in the inspector 🙂.

🍪