Skip to content

Custom Drawing

While Tecs uses a deferred, GPU-accelerated render pipeline for most things you'll need like sprites, shapes, and text, you can still draw using standard Love2D drawing calls.

Draw Phase

The Draw phase lets you make custom love.graphics draw calls that participate in the world's lighting and depth sorting alongside GPU-rendered entities.

teal
local tecs = require("tecs")
local gfx = require("tecs2d.gfx")

local pipeline = world.resources[gfx.PIPELINE]

world:addSystem({
    name = "custom.WorldDraw",
    phase = tecs.phases.Draw,
    run = function()
        -- Attach shader for lit drawing at layer 5
        pipeline:worldShader():at(5, 0, 100, 250):attach()

        -- Draw in world coordinates (camera transform already applied)
        love.graphics.setColor(1, 0.5, 0, 1)
        love.graphics.circle("fill", 100, 200, 30)

        pipeline:detachWorldShader()
    end
})

Camera transforms already applied

During Draw, the camera's position and zoom are already applied to the Love2D graphics state. All draw calls use world coordinates, the same coordinate space as GPU entities. Drawing at (100, 200) places your content at world position (100, 200), automatically offset and scaled by the camera.

World Shader API

To integrate with the G-buffer and lighting system, use pipeline:worldShader() to get a fluent configuration object. Chain methods to set depth sorting, lighting mode, and optional shader/normal map, then call :attach() to apply. Call pipeline:detachWorldShader() after drawing.

Draw with light with a position and layer:

teal
pipeline:worldShader()
    :at(layer, z, x, bottomY)
    :attach()

Unlit draw (full brightness, no lighting):

teal
pipeline:worldShader()
    :at(layer)
    :unlit()
    :attach()

Draw with custom shader and normal map:

teal
pipeline:worldShader()
    :at(layer, z, x, bottomY)
    :shader(s)
    :normalMap(tex)
    :attach()

Always detach after drawing:

teal
pipeline:detachWorldShader()

:at(layer, z?, x?, bottomY?)

Set the depth sorting position. Only layer is required; z, x, and bottomY default to 0.

ParameterDescription
layerRender layer (1-16)
zZ-index within layer (default 0)
xX coordinate for isometric depth sorting (default 0)
bottomYBottom Y coordinate for depth sorting (default 0)

:unlit()

Mark the draw as unlit. The draw will render at full brightness and not receive lighting.

:shader(loveShader)

Set a raw love.graphics.Shader for custom MRT-aware rendering. See Custom Shader Requirements below.

:normalMap(texture)

Set a normal map texture for per-pixel lighting.

:attach()

Apply the world shader with the accumulated settings. Draw after this call.

Custom Shader Requirements

Custom shaders passed to :shader() must be MRT-aware to receive lighting (Multiple Render Targets).

MRT Output Required

Custom shaders that don't output to love_Canvases[1] (the normal buffer) will not receive lighting. The entity will appear black or only show ambient light.

Materials vs World Shaders

For per-entity visual effects (dissolve, tint, glow), use Materials instead. Materials are GPU-batched and much faster. The :shader() API here is for manual CPU draws only (particles, physics debug, custom procedural rendering).

Tecs Uniforms

When a custom shader is set via :shader(), Tecs automatically sends values for these uniforms if they are declared:

UniformTypeDescription
tecs_DepthfloatDepth value for z-sorting (0-1, lower = closer)
tecs_NormalMapImageNormal map texture (or flat normal fallback)
tecs_Litfloat1.0 = receives lighting, 0.0 = unlit/fullbright
tecs_TimefloatGame time in seconds (respects time scale and pause)

MRT Shader Template

A complete shader for use with :shader():

glsl
#pragma language glsl4

uniform Image MainTex;
uniform float tecs_Depth;
uniform Image tecs_NormalMap;
uniform float tecs_Lit;
uniform float tecs_Time;

#ifdef VERTEX
vec4 position(mat4 transform_projection, vec4 vertex_position) {
    vec4 result = transform_projection * vertex_position;
    result.z = tecs_Depth * result.w;
    return result;
}
#endif

#ifdef PIXEL
void effect() {
    vec4 albedo = Texel(MainTex, VaryingTexCoord.xy) * VaryingColor;
    if (albedo.a < 0.01) discard;
    vec3 normal = Texel(tecs_NormalMap, VaryingTexCoord.xy).rgb * 2.0 - 1.0;
    love_Canvases[0] = albedo;
    love_Canvases[1] = vec4(normal * 0.5 + 0.5, tecs_Lit);
}
#endif

When you call :attach() without :shader(), the built-in world shader is used. This shader:

  • Outputs to the G-buffer (albedo + normals)
  • Respects :unlit() for lit/unlit rendering
  • Applies the normal map if provided via :normalMap()
  • Sets depth for correct sorting with GPU entities

Unlit Drawing

For draws that should ignore lighting (full brightness):

teal
pipeline:worldShader()
    :at(layer, z, x, bottomY)
    :unlit()
    :attach()

love.graphics.rectangle("fill", x, y, w, h)
pipeline:detachWorldShader()

Visibility Culling

For performance, cull draws outside the visible area using the camera's isVisible method:

teal
local cam = pipeline:getCamera()

world:addSystem({
    name = "custom.CulledDraw",
    phase = tecs.phases.Draw,
    run = function()
        for _, obj in ipairs(objects) do
            if cam:isVisible(obj.x, obj.y, obj.w, obj.h) then
                pipeline:worldShader()
                    :at(obj.layer, 0, obj.x, obj.y + obj.h)
                    :attach()
                love.graphics.setColor(obj.r, obj.g, obj.b, 1)
                love.graphics.rectangle("fill", obj.x, obj.y, obj.w, obj.h)
                pipeline:detachWorldShader()
            end
        end
    end
})

PostRender Phase

Use PostRender for screen-space overlays that render after the entire GPU pipeline (no lighting, no depth sorting with world entities).

teal
world:addSystem({
    name = "ui.Overlay",
    phase = tecs.phases.PostRender,
    run = function()
        -- Screen coordinates (no camera transform)
        love.graphics.setColor(1, 1, 1, 1)
        love.graphics.print("FPS: " .. love.timer.getFPS(), 10, 10)
    end
})

UI in World Space

For UI that should use the layer system but not receive lighting, configure a layer as virtual space and unlit:

teal
pipeline:configureLayer(15, {
    name = "ui",
    space = "virtual",
    unlit = true
})

-- Then in the Draw phase, draw to that layer
world:addSystem({
    name = "ui.HUD",
    phase = tecs.phases.Draw,
    run = function()
        pipeline:worldShader()
            :at(15)
            :unlit()
            :attach()

        -- Virtual coordinates (e.g., 0-320 x 0-180)
        love.graphics.setColor(1, 1, 1, 1)
        love.graphics.rectangle("fill", 10, 10, 100, 20)

        pipeline:detachWorldShader()
    end
})

Depth Calculation

The pipeline computes depth using a consistent formula for both GPU and CPU draws:

teal
local depth = pipeline:computeDepth(layer, z, x, bottomY)

Lower depth values render in front of higher values. The formula depends on the layer's sort mode:

  • topdown (default): Layer, then Z-index, then Y position
  • z: Layer, then Z-index only (no spatial sorting)
  • isometric: Layer, then X + Y + Z combined

Camera State

During Draw, access camera state if needed:

teal
local camX = pipeline.drawCamX
local camY = pipeline.drawCamY
local zoom = pipeline.drawZoom

Performance Notes

  • CPU draws described on this page are slower than GPU-instanced rendering
  • Batch similar draws together when possible
  • Consider using the Mesh component for custom geometry instead