Logo Signal From The Stars

Camera

View it with a camera

Martin avatar
  • Martin
  • 6 min read
viewport

Viewport

In the article engine3 you could already see what the Camera class looks like. Something I was worried about and questioned was how I was going to scale everything up.

Briefly, there is the problem that each device has a different screen resolution. Somewhere I decided that the maximum height in Photoshop is 240 pixels and the width is unlimited. Then the configuration of love2d was set in the following way.

-- https://en.wikipedia.org/wiki/Display_resolution
--
-- 4:3
-- 1x 320 x 240 -- < minimal source background height
-- 2x 640 x 480
--
-- 16:9
-- 4x 1280x720
-- 6x 1920x1080 (fullHD)
-- 12x 3840x2160 (4K)
t.window.width = 1280 -- The window width (number)
t.window.height = 720 -- The window height (number)
t.window.usedpiscale = true -- Enable automatic DPI scaling (boolean)
t.window.highdpi = true -- Enable high-dpi mode for the window on a Retina display (boolean)
t.window.vsync = 1 -- Vertical sync mode (number)
t.window.msaa = 0 -- The number of samples to use with multi-sampled antialiasing (number)
t.window.depth = nil -- The number of bits per sample in the depth buffer
t.window.stencil = nil -- The number of bits per sample in the stencil buffer

What is important to know is that everything is drawn pixel perfect, this allows scaling the images with Nearest Neighbor (preserve hard edges). This is a big advantage than if I were to use smooth, as that requires a complicated scaling method.

Also important is that in love2d I say the following

love.graphics.setDefaultFilter("nearest", "nearest")
love.graphics.setLineStyle("rough")

Canvas

In love2d, I use a canvas on which the complete game is drawn. The height and width of the canvas is determined by my own config, and Canvas is also engine3 class.

config.lua

self.canvas = {
    width = 320,
    height = 240,
    integerScaling = true,
    subpixels = 4
}
function Canvas:new(data)
...
    self.canvas = love.graphics.newCanvas(self.width * self.subpixels, self.height * self.subpixels)
    self.canvas:setFilter("nearest", "nearest")
...
end

---Only execute this on init and window resize
function Canvas:calculateRenderInfo()
    local _, _, windowWidth, windowHeight = love.window.getSafeArea()
    local canvasWidth, canvasHeight = self:getDimensions()

    -- Fill as much of the window as possible with the canvas while preserving the aspect ratio.
    self.scale = math.min(windowWidth / canvasWidth, windowHeight / canvasHeight)
    --self.scale = windowHeight / canvasHeight -- This would fill the height and possibly cut off the sides.

    if self.integerScaling then
        self.scale = math.floor(self.scale * self:getSubpixels()) / self:getSubpixels()
        self.scale = math.max(self.scale, 1 / self:getSubpixels()) -- Avoid self.scale =0 if the window is tiny!
    end

    self.scaledWidth = canvasWidth * self.scale
    self.scaledHeight = canvasHeight * self.scale

    -- Center the canvas on the screen.
    self:setX(math.floor((windowWidth - self.scaledWidth) / 2))
    self:setY(math.floor((windowHeight - self.scaledHeight) / 2))

    -- These are cached now
    return self:getX(), self:getY(), self:getScale(), self:getPixelScale()
end
function love.resize(w, h)
    -- re-calculate so we cache the new variables
    -- @warning this is needed first, before we forward to the world
    self.canvas:calculateRenderInfo()

    -- forward to other entities
    if self.world then
        self.world:windowResizeEvent(
            {
                width = w,
                height = h
            }
        )
    end
end

And that’s all to enlarge everything to infinity with a minimum screen height of 240 pixels. I still have to test multiple screens, but it looks good on different resolutions so far and the window resizing works well too.

Camera

I have set the camera to follow a chosen entity, which is the center point where the camera is aimed. What is important now is that the height of the Canvas always has a certain height and width, but the ‘Scene’ is much wider, e.g. 2000px. The camera therefore also gets a width and a height from the viewport (canvas) and when the entity to be followed moves, it will not move itself but will change the complete way how love2d draws everything in terms of coordinates on the canvas. This gives you the illusion that the camera is moving to the right. But in reality all objects move to the left.

function Camera:attach()
    love.graphics.push("all")

    -- Scroll to the camera's position.
    local cornerX = self.x - self.width / 2
    local cornerY = self.y - self.height / 2
    -- Negative values because as the camera moves to the right, the world moves to the left etc.
    love.graphics.translate(
        Geometry.round(-cornerX, self.config.canvas.subpixels),
        Geometry.round(-cornerY, self.config.canvas.subpixels)
    )
end

function Camera:detach()
    love.graphics.pop()
end

---@param x integer
---@param y integer
function Camera:setTarget(x, y)
    if self:getWaitUntilOnTarget() then
        if Tween:isActive("cameraFollow") then
            print("camera is busy and getWaitUntilOnTarget is in use.")
            return
        end

        -- at the moment if you want to wait then you don't use the player static camera
        -- it will follow the camera from a to b
        self:setTargetX(x)
        self:setTargetY(y)

        Tween.to(self, self.followSpeed, {x = self.targetX, y = self.targetY}, "cameraFollow"):ease("linear"):onupdate(
            function()
                if self.bound then
                    self.x =
                        math.min(
                        math.max(self.x, self.bounds_min_x + self.width / 2),
                        self.bounds_max_x - self.width / 2
                    )
                    self.y =
                        math.min(
                        math.max(self.y, self.bounds_min_y + self.height / 2),
                        self.bounds_max_y - self.height / 2
                    )
                end
            end
        ):oncomplete(
            function()
                self:callback(self.callbackAfterStop, self)
            end
        )
    else
        self:setTargetX(x)
        self:setTargetY(y)

        self.x = self.targetX
        self.y = self.targetY

        -- Ensure the camera stays within bounds
        if self.bound then
            self.x = math.min(math.max(self.x, self.bounds_min_x + self.width / 2), self.bounds_max_x - self.width / 2)
            self.y =
                math.min(math.max(self.y, self.bounds_min_y + self.height / 2), self.bounds_max_y - self.height / 2)
        end
    end
end

Order of drawing

You’ve already read about the zindex in another article, but this one is about the order of entities on the screen, canvas and camera. This is the main loop that handles everything correctly.

...
function Engine3:draw()
	if self.world == nil then
		return
	end

	-- Draw to the canvas
	love.graphics.push("all")
	love.graphics.setCanvas(self.canvas:getCanvas())
	love.graphics.clear()
	love.graphics.scale(self.canvas:getSubpixels())

	if self.camera then
		self.camera:attach()
	end

	self.world:draw()

	-- Draw things on screen, not in the canvas and translations, for example a pointer
	for _, entity in pairs(self:getEntitiesToDraw("onCanvas")) do
		entity:draw()
	end

	if
		self.loadingScreen ~= nil and self.loadingScreen:getIsVisible() == true and
			self.loadingScreen:getRenderOtherLayers() == false
	 then
		self.loadingScreen:draw()
		self.loadingScreen.textObj:draw()
	end

	if self.camera then
		self.camera:detach()
		self.camera:draw() -- camera effects if needed
	end

	-- draw things within the canvas but without the moving camera
	for _, entity in pairs(self:getEntitiesToDraw("insideCanvasWithoutCameraMovement")) do
		entity:draw()
	end

	-- Canvas drawing end
	love.graphics.pop()
	love.graphics.clear(0, 0, 0)

	-- don't draw the canvas if there is a loadingScreen
	if
		self.loadingScreen == nil or self.loadingScreen:getIsVisible() == false or
			self.loadingScreen:getRenderOtherLayers() == true
	 then
		love.graphics.draw(self.canvas:getCanvas(), self.canvas:getX(), self.canvas:getY(), 0, self.canvas:getScale())

	-- debug
	-- love.graphics.rectangle(
	-- 	"line",
	-- 	self.canvas:getX(),
	-- 	self.canvas:getY(),
	-- 	self.canvas:getWidth() * self.canvas:getPixelScale(),
	-- 	self.canvas:getHeight() * self.canvas:getPixelScale()
	-- )
	end

	-- Draw things on screen, not in the canvas and translations, for example a pointer
	for _, entity in pairs(self:getEntitiesToDraw("onScreen")) do
		entity:draw()
	end

	if self.config.showFPS then
		love.graphics.print("FPS: " .. tostring(love.timer.getFPS()), 10, 10)
		love.graphics.print(
			string.format(
				"Memory: gfx=%.1fMiB lua=%.1fMiB",
				love.graphics.getStats().texturememory / 1024 ^ 2,
				collectgarbage "count" / 1024
			),
			10,
			25
		)
	end

	if self.config.showMouseXY then
		local mouseXOnScreen, mouseYOnScreen = love.mouse.getPosition()
		local text = string.format("\nMouse screen: %d,%d", mouseXOnScreen, mouseYOnScreen)
		if self.pointer then
			text = text .. string.format("\nScreenToWorld: %d,%d", self.pointer:getScreenToWorld(mouseXOnScreen, mouseYOnScreen))
		end

		love.graphics.print(text, 10, 30)
	end
end
...

Finally

The camera was a tricky piece of code to understand, fortunately I received a lot of examples and support for this on love2d’s forum. Had I not had that then this was probably going to take me too much time and I don’t know how it would have turned out.

I also suffered from jitter, that the camera is restless. I solved this by only changing the x while tracking the entity.

-- don't jitter, we only move left <> right
local newTargetY = self.targetY
if self.targetY == nil then
    if self.followEntity.activeFrameset then
        newTargetY = self.followEntity.activeFrameset:getY()
    else
        newTargetY = self.followEntity:getY() or self.followEntity:getFrameY()
    end
end

Loading external comment system...

βœ‰οΈ Stay informed!

Receive the latest news for free and motivate me to make this adventure a success!