Code Sample: Lighting in 3D meshes

I started working with meshes but I’m missing things like lighting. My solution would be to calculate it on my own from the angle between light and eye to vertex. But before doing that I want to ask if there is a solution in Codea which I have missed?

There is no support for per-vertex lighting (besides computing it yourself and setting the vertex colours).

However we will be looking into this in the form of shaders, in the future.

Ok, wanted to find this out in more detail for a project I’m working on and created a sample I want to share here. It has three modes:

  • Render all blocks with the same light vector and light is fixed in scene → Only one cube is lighted and the calculation takes place only one time at init.

  • Render all blocks with the same light vector, but the light is not always the same. When the model rotates, the light stays and the lighting has to be recalculated. But also only one cube is lighted and copied.

  • Render all blocks with a given light position. The light is then calculated for each vertex. This is the best lighting, but has to be calculated for each block, as the angle to the light changes. This is used for light sources close to the objects.

There is space for optimization, e.g. the normal vector could be precalculated.

It should be possible to use the light calc functions for other meshes too, as long as the mesh is also build like in the example.

Main.lua

-- 3D light test
--   by Kilam Malik
--
-- rotate und damping in lib, only call one time.
--
-- Shows a few OpenGL lighting techniques calculated with Codea. Of course there are other
-- combinations possible, like dynamic light, but only for faces etc.
--
-- With 25 blocks in full dynamic per vertex the framerate starts to drop. Limited to 25 blocks
-- in code. But with simple lighting it can handle much more.
--
-- There is lots of space for optimization, like saving the precalculated normal vectors.
--
-- You should be able to use the light calc functions on any meshes definded in the same way
-- like here in the createBlock function, which I copied from the samples.

lightModeOld = -1
ambientLightOld = -1

rotMatrix = matrix()
autoRotateVelocity = vec2(0, 0)
prevTouchPos = vec2(0, 0)
noTouch = false
touchID = -1

blocks = {}
singleBlock = nil
lightBlock = nil

function setup()
    displayMode(STANDARD)
    iparameter("lightMode", 1, 3, 1)
    iparameter("nrBlocks", 1, 250, 25)

    parameter("ambientLight", 0.0, 1.0, 0.35)
    parameter("autoRotateDamping", 0.01, 1.00, 0.15)

    -- Creating one block for the simple light modes, where the block is the same
    -- for all copies:
    local m = matrix()
    singleBlock = createBlock("Planet Cute:Stone Block", m, true)

    -- Creating 25 block meshes for the last and complex light mode, where the light is calculated
    -- for each block:
    math.randomseed(64642)
    for i = 1, 50 do
        m = matrix()
        local p = vec3(math.random(-5, 5), math.random(-5, 5), math.random(-5, 5))
        m = m:translate(p.x, p.y, p.z)
        local block = createBlock("Planet Cute:Stone Block", m, true)
        table.insert(blocks, block)
    end

    -- To visualize the light a block is used also:
    m = matrix()
    local img = image(100, 100)
    setContext(img)
        pushStyle()
            noStroke()
            fill(255, 80, 0, 255)
            rect(0,0,100,100)
            fill(255, 134, 0, 255)
            ellipse(50, 50, 100)
            fill(255, 178, 0, 255)
            ellipse(50, 50, 80)
            fill(255, 194, 0, 255)
            ellipse(50, 50, 60)
            fill(255, 218, 0, 255)
            ellipse(50, 50, 40)
        popStyle()
    setContext()
    lightBlock = createBlock(img, m, false)
end

function draw()
    if autoRotateVelocity:len() > 0.001 and noTouch then
        autoRotateVelocity = autoRotateVelocity * (1 - autoRotateDamping)

        -- Rotation without gimbal lock by only multiplying delta rotations
        -- to a matrix.
        local delta = autoRotateVelocity / 5
        rotMatrix = mouseRotateXY(delta, rotMatrix)
    end

    -- This sets a dark background color 
    background(0, 0, 0)
    perspective(50, WIDTH/HEIGHT)

    local name
    if lightMode == 1 then
        -- Do a one time calculation of the lighting when this mode is set and ambient has changed:
        if lightMode == lightModeOld and ambientLight == ambientLightOld then
        else
            local lightVec = vec3(1.5, 0.75, 3)
            singleBlock.colors = calcLightingForLightVectorPerFace(singleBlock.vertices, lightVec, ambientLight)
        end

        drawStaticLighting()
        name = "Light as Vector, static light in scene"
        description = "The light rotates with the scene, so the lighting is the same all the time. Only calculated "..
                      "once and for one block as the light is only a vector and therefore same for all blocks."
    elseif lightMode == 2 then
        drawDynamicVectorLighting()
        name = "Light as Vector, dynamic light in scene"        
        description = "The light is fixed while the scene rotates. Has to be recalced every frame, but only for one block, "..
                      "because also the same for all as the light is a vector."
    elseif lightMode == 3 then
        drawDynamicPerVertexLighting()
        name = "Light as position with light per vertex, dynamic light in scene"
        description = "The light is given as position here, so it has to be recalced for every vertex of all blocks. Also the "..
                      "light moves so has to calculate every frame. Gives the best effect, as light can be different inside one face."
    end

    lightModeOld = lightMode
    ambientLightOld = ambientLight

    ortho()
    viewMatrix(matrix())
    pushStyle()
        fill(255, 255, 255, 255)
        fontSize(20)
        textMode(CENTER)
        text(name, WIDTH/2, HEIGHT - 10)
        fontSize(12)
        textWrapWidth(WIDTH*0.9)
        text(description, WIDTH/2, HEIGHT - 50)
    popStyle()
end

function drawStaticLighting()
    pushMatrix()
        pushStyle()
            translate(0, 0, -18)
            applyMatrix(rotMatrix)

            -- all blocks are lighted identical, so only drawmthe first
            -- block nrBlocks times:
            math.randomseed(64642)
            for i = 1, nrBlocks do
                pushMatrix()
                    translate(math.random(-5, 5), math.random(-5, 5), math.random(-5, 5))
                    singleBlock:draw()
                popMatrix()
            end
        popStyle()
    popMatrix()
end

function drawDynamicVectorLighting()
    -- Calc the lighting for the first block and then draw it nrBlocks times.
    -- Instead of rotating all vertices of the block, the light is rotated
    -- with the inverse matrix to save time:
    local lightVec = vec3(1.5, 0.75, 3)
    local m = rotMatrix:inverse()
    lightVec = mulMatVec(m, lightVec)
    singleBlock.colors = calcLightingForLightVectorPerFace(singleBlock.vertices, lightVec, ambientLight)

    drawStaticLighting()
end

function drawDynamicPerVertexLighting()
    local lightPos = vec3(5*math.sin(ElapsedTime/5), 5*math.cos(ElapsedTime/3), 5*math.sin(ElapsedTime/7))
    pushMatrix()
            translate(0, 0, -18)
            applyMatrix(rotMatrix)
        translate(lightPos.x, lightPos.y, lightPos.z)
        scale(0.2, 0.2, 0.2)
        lightBlock:draw()
    popMatrix()

    for i = 1, math.min(nrBlocks, 50) do
        blocks[i].colors = calcLightingForLightPosPerVertex(blocks[i].vertices, lightPos, ambientLight)
    end

    pushMatrix()
        pushStyle()
            translate(0, 0, -18)
            applyMatrix(rotMatrix)

            for i = 1, math.min(nrBlocks, 50) do
                blocks[i]:draw()
            end
        popStyle()
    popMatrix()
end

function touched(touch)
    if touch.state == BEGAN and touchID == -1 then
        prevTouchPos = vec2(touch.x, touch.y)
        noTouch = false
        touchID = touch.id
    elseif touch.state == MOVING and touch.id == touchID then
        local delta = (vec2(touch.x, touch.y) - prevTouchPos) / 5.0
        rotMatrix = mouseRotateXY(delta, rotMatrix)

        autoRotateVelocity = vec2(touch.x, touch.y) - prevTouchPos
        prevTouchPos = vec2(touch.x, touch.y)
    elseif touch.state == ENDED and touch.id == touchID then
        noTouch = true
        touchID = -1
    end
end

3DFunctions.lua

function mulMatVec(m, v)
    -- Multiply matrix with vector
    local vn = vec3()
    vn.x = m[1]*v.x + m[5]*v.y + m[9]*v.z + m[13]
    vn.y = m[2]*v.x + m[6]*v.y + m[10]*v.z + m[14]
    vn.z = m[3]*v.x + m[7]*v.y + m[11]*v.z + m[15]
    return vn
end

function mouseRotateXY(delta, rotMatrix)
    -- Rotation without gimbal lock by only multiplying delta rotations
    -- to a matrix. Pass delta vec2 (x and y) and current rotation matrix. New
    -- rotation matrix is returned.
    local rm = matrix()
    rm = rm:rotate(delta.x, 0, 1, 0)
    rm = rm:rotate(-delta.y, 1, 0, 0)
    return rotMatrix * rm
end
    
function calcLightingForLightVectorPerFace(vertices, lightVec, ambientLight)
    -- Calculates the light per face with the light vector and set it for
    -- all vertices of a triangle.
    -- Walks the vertices structure of a mesh and returns a color structure.
    local colors = {}
    local ambVal = 255 * ambientLight
    local ambValOneMinus = 255 - ambVal

    i = 1
    lightVec = lightVec:normalize()

    while i < table.maxn(vertices) do
        local v1 = vertices[i]
        local v2 = vertices[i+1]
        local v3 = vertices[i+2]

        local va = v2-v1
        local vb = v3-v1
        local v = va:cross(vb)
        v:normalize()
        local cv = ambVal + math.max(0, v:dot(lightVec)*ambValOneMinus)
        local c = color(cv,cv,cv,255)

        colors[i] = c
        colors[i+1] = c
        colors[i+2] = c
        i = i + 3
    end
    return colors
end

function calcLightingForLightPosPerVertex(vertices, lightPos, ambientLight)
    -- Calculates the light per vertex with the light position and set it for
    -- all vertices of a triangle.
    -- Walks the vertices structure of a mesh and returns a color structure.
    local colors = {}
    local ambVal = 255 * ambientLight
    local ambValOneMinus = 255 - ambVal

    i = 1
    while i < table.maxn(vertices) do
        local v1 = vertices[i]
        local v2 = vertices[i+1]
        local v3 = vertices[i+2]

        local va = v2-v1
        local vb = v3-v1
        local v = va:cross(vb)
        v:normalize()
        local lightVec = lightPos - v1
        local cv = ambVal + math.max(0, v:dot(lightVec:normalize())*ambValOneMinus)
        local c = color(cv,cv,cv,255)
        colors[i] = c

        lightVec = lightPos - v2
        cv = ambVal + math.max(0, v:dot(lightVec:normalize())*ambValOneMinus)
        c = color(cv,cv,cv,255)
        colors[i+1] = c

        lightVec = lightPos - v3
        cv = ambVal + math.max(0, v:dot(lightVec:normalize())*ambValOneMinus)
        c = color(cv,cv,cv,255)
        colors[i+2] = c
        i = i + 3
    end

    return colors
end

function createBlock(tex, m, isBrick)
    -- Base code used from Codea sample. Setup a cube in world center with size 1. Multiplied with matrix m:
    local vertices = {}

    table.insert(vertices, mulMatVec(m, vec3(-0.5, -0.5,  0.5))) -- Left  bottom front
    table.insert(vertices, mulMatVec(m, vec3( 0.5, -0.5,  0.5))) -- Right bottom front
    table.insert(vertices, mulMatVec(m, vec3( 0.5,  0.5,  0.5))) -- Right top    front
    table.insert(vertices, mulMatVec(m, vec3(-0.5,  0.5,  0.5))) -- Left  top    front
    table.insert(vertices, mulMatVec(m, vec3(-0.5, -0.5, -0.5))) -- Left  bottom back
    table.insert(vertices, mulMatVec(m, vec3( 0.5, -0.5, -0.5))) -- Right bottom back
    table.insert(vertices, mulMatVec(m, vec3( 0.5,  0.5, -0.5))) -- Right top    back
    table.insert(vertices, mulMatVec(m, vec3(-0.5,  0.5, -0.5))) -- Left  top    back    

    -- now construct a cube out of the vertices above
    local cubeverts = {
      -- Front
      vertices[1], vertices[2], vertices[3],
      vertices[1], vertices[3], vertices[4],
      -- Right
      vertices[2], vertices[6], vertices[7],
      vertices[2], vertices[7], vertices[3],
      -- Back
      vertices[6], vertices[5], vertices[8],
      vertices[6], vertices[8], vertices[7],
      -- Left
      vertices[5], vertices[1], vertices[4],
      vertices[5], vertices[4], vertices[8],
      -- Top
      vertices[4], vertices[3], vertices[7],
      vertices[4], vertices[7], vertices[8],
      -- Bottom
      vertices[5], vertices[6], vertices[2],
      vertices[5], vertices[2], vertices[1],
    }

    -- all the unique texture positions needed. special for stone block for
    -- the forum post, but normally you would use full size textures here:
    local texvertices = {}
    if isBrick then
        texvertices = { vec2(0.03,0.24),
                              vec2(0.97,0.24),
                              vec2(0.03,0.69),
                              vec2(0.97,0.69) }
    else
        -- all the unique texture positions needed
        texvertices = { vec2(0,0),
                              vec2(1,0),
                              vec2(0,1),
                              vec2(1,1) }
    end

    -- apply the texture coordinates to each triangle
    local cubetexCoords = {
      -- Front
      texvertices[1], texvertices[2], texvertices[4],
      texvertices[1], texvertices[4], texvertices[3],
      -- Right
      texvertices[1], texvertices[2], texvertices[4],
      texvertices[1], texvertices[4], texvertices[3],
      -- Back
      texvertices[1], texvertices[2], texvertices[4],
      texvertices[1], texvertices[4], texvertices[3],
      -- Left
      texvertices[1], texvertices[2], texvertices[4],
      texvertices[1], texvertices[4], texvertices[3],
      -- Top
      texvertices[1], texvertices[2], texvertices[4],
      texvertices[1], texvertices[4], texvertices[3],
      -- Bottom
      texvertices[1], texvertices[2], texvertices[4],
      texvertices[1], texvertices[4], texvertices[3],
    }

    b = mesh()
    b.vertices = cubeverts
    b.texture = tex
    b.texCoords = cubetexCoords
    b:setColors(255,255,255,255)
    return b
end

Very nice, @Kilam. That looks fantastic. Especially the moving point light sample.

OMG that is amazing :open_mouth: