Justified text displaying one character at a time

Questions about the LÖVE API, installing LÖVE and other support related questions go here.
Forum rules
Before you make a thread asking for help, read this.
RNavega
Party member
Posts: 251
Joined: Sun Aug 16, 2020 1:28 pm

Re: Justified text displaying one character at a time

Post by RNavega »

darkfrei wrote: Sun Dec 17, 2023 9:21 am Shaders are not so easy, can you please make the .love file with the simplest realization?
Okay.
I noticed a couple of things:
1) It actually needs 3 rays, not just 2: one to progressively reveal characters on the same line, one to show all previous lines, and one to hide all lines below.

2) This trick works better with monospace (AKA fixed width) fonts, because there are some kerning pairs in non-monospace fonts where the letters overlap horizontally, so there's no way that a vertical ray can isolate only one of the characters.
This is a picture of the problem, look how the capital T is fully visible, but since it overhangs on that lowercase E, part of that E is also revealed:
fontKerningProblem.png
fontKerningProblem.png (8.07 KiB) Viewed 15012 times
This problem doesn't happen with monospace/fixed width fonts, where each glyph is entirely contained within its own "box".

That said, here's the .love in the attachments. Included is a monospaced font, Bitstream Vera Sans Mono (Bold).
I'll also paste the main.lua below in case someone just wants to read it without having to extract it from the .love:
transparentShaderDialogText.gif
transparentShaderDialogText.gif (30.56 KiB) Viewed 15012 times

Code: Select all

-- =======================================
-- Shader-based text reveal effect.
-- Version 0.2
--
-- By Rafael Navega (2023)
-- License: Public Domain
-- =======================================
io.stdout:setvbuf('no')

local utf8 = require('utf8')


-- Bitstream Vera Sans Mono, Bold.
local font = love.graphics.newFont("Bitstream Vera Sans Mono/VeraMoBd.ttf", 16)
font:setLineHeight(1.2)
love.graphics.setFont(font)

local text = [[An old silent pond
A frog jumps into the pond—
Splash! Silence again.]]

local CHARS_PER_SEC = 16.0

local textObject
local charOffsets
-- Start with a negative index so it waits for a bit before showing anything.
local offsetIndex = -6

local textGeometry = {
    TEXT_DRAW_X = 160,
    TEXT_DRAW_Y = 20,
    canStepLine = true,
    lineRevealY = 0.0,
    charRevealX = 0.0,
    charHideY   = 0.0
}

local pixelShaderSource = [[
extern float charRevealX;
extern float charHideY;
extern float lineRevealY;

// Minimum transparency possible. Set this to zero to completely hide characters.
const float MINIMUM_ALPHA = 0.1;

vec4 effect(vec4 color, Image tex, vec2 texture_coords, vec2 screen_coords)
{
    vec4 texturecolor = Texel(tex, texture_coords);
    float charRevealAlpha = max(
        step(screen_coords.y, lineRevealY),
        step(screen_coords.x, charRevealX) * step(screen_coords.y, charHideY)
    );
    texturecolor.a *= max(MINIMUM_ALPHA, charRevealAlpha);
    return texturecolor * color;
}
]]
local textShader


-- Create an offsets table with the steps in pixels between each character in the text.
-- When the Y is unchanged, store only the absolute X position.
-- When a linebreak is found, store both the X and Y in a child table.
-- So later when iterating this offsets table, any table elements will indicate
-- a change in both X and Y, and direct numbers will only indicate a change in X.
local function makeCharOffsets(text, font, textGeometry)
    local charOffsets = {}
    local lineHeight = font:getHeight() * font:getLineHeight()

    local xPosition = 0.0
    local yPosition = lineHeight
    for c in text:gmatch(utf8.charpattern) do
        if c == '\n' then
            -- Reset the absolute position, X goes back to zero and Y advances
            -- by 1 line.
            xPosition = 0.0
            yPosition = yPosition + lineHeight
            table.insert(charOffsets, {xPosition, yPosition})
            print(#charOffsets, xPosition, yPosition)
        else
            -- Advance the absolute X position.
            local width = font:getWidth(c)
            xPosition = xPosition + width
            table.insert(charOffsets, xPosition)
            print(#charOffsets, xPosition)
            -- You can add some custom text wrapping in here, if the accumulated width
            -- overflows a limit etc.
        end
    end
    -- Make sure that the first element of this offsets table is a child table so that
    -- the code that uses it later will reset both X and Y right at the start.
    charOffsets[1] = {charOffsets[1], lineHeight}
    -- Duplicate the last element (so no change in position) so the final character
    -- can be displayed.
    table.insert(charOffsets, charOffsets[#charOffsets])
    return charOffsets
end


function love.load()
    love.graphics.setBackgroundColor(0.05, 0.1, 0.3)
    charOffsets = makeCharOffsets(text, font)
    textGeometry.charRevealX = textGeometry.TEXT_DRAW_X
    textGeometry.charHideY   = textGeometry.TEXT_DRAW_Y
    textGeometry.lineRevealY = textGeometry.TEXT_DRAW_Y

    textShader = love.graphics.newShader(pixelShaderSource)
    -- Create a static Text object, to prove that the animation is being
    -- done in the shader and not at the string level.
    textObject = love.graphics.newText(font, text)
end


function love.update(dt)
    -- Just for debug, hold at the end for about this many characters long.
    local RESET_DELAY = 7

    offsetIndex = offsetIndex + CHARS_PER_SEC * dt
    if offsetIndex > (#charOffsets + RESET_DELAY) then
        offsetIndex = -6
        -- Reset the text-revealing geometry when restarting the scanning.
        textGeometry.lineRevealY = textGeometry.TEXT_DRAW_Y
        textGeometry.charRevealX = textGeometry.TEXT_DRAW_X
        textGeometry.charHideY   = textGeometry.TEXT_DRAW_Y
        textGeometry.canStepLine = true
    else
        local currentOffset = charOffsets[math.floor(offsetIndex)]
        if currentOffset then
            if type(currentOffset) == 'table' then
                -- Make sure to only step a line once, because on consecutive frames
                -- the SAME X,Y table might be sampled many times before 'offsetIndex'
                -- accumulates up to the next whole number.
                if textGeometry.canStepLine then
                    textGeometry.lineRevealY = textGeometry.charHideY
                    textGeometry.charRevealX = currentOffset[1] + textGeometry.TEXT_DRAW_X
                    textGeometry.charHideY = currentOffset[2] + textGeometry.TEXT_DRAW_Y
                    textGeometry.canStepLine = false
                end
            else
                -- A simple number element indicates a change in the absolute X position
                -- and the Y position stays the same.
                textGeometry.charRevealX = currentOffset + textGeometry.TEXT_DRAW_X
                textGeometry.canStepLine = true
            end
        end
    end
end


function love.draw()
    love.graphics.setColor(0.0, 0.0, 0.0)
    love.graphics.circle('fill', 80, 20, 200)

    -- Draw the shader lines.
    -- Change this between true/false for debugging.
    if true then
        love.graphics.setColor(1.0, 0.0, 0.0, 0.7)
        love.graphics.line(textGeometry.charRevealX, 0, textGeometry.charRevealX, 120)
        love.graphics.setColor(0.0, 0.7, 1.0, 0.7)
        love.graphics.line(0, textGeometry.lineRevealY, 600, textGeometry.lineRevealY)
        love.graphics.setColor(1.0, 1.0, 0.0, 0.7)
        love.graphics.line(0, textGeometry.charHideY, 600, textGeometry.charHideY)
    end

    love.graphics.setColor(1.0, 1.0, 1.0)
    love.graphics.setShader(textShader)
    textShader:send('charRevealX', textGeometry.charRevealX)
    textShader:send('charHideY', textGeometry.charHideY)
    textShader:send('lineRevealY', textGeometry.lineRevealY)
    love.graphics.draw(textObject, textGeometry.TEXT_DRAW_X, textGeometry.TEXT_DRAW_Y)
    love.graphics.setShader()
end


function love.keypressed(key)
    if key == 'escape' then
        love.event.quit()
    end
end
Edit: updated to version 0.2 with a bugfix.
Attachments
Shader_Text_Reveal_v0.2.love
(31.25 KiB) Downloaded 92 times
Last edited by RNavega on Sun Dec 17, 2023 9:37 pm, edited 1 time in total.
User avatar
darkfrei
Party member
Posts: 1181
Joined: Sat Feb 08, 2020 11:09 pm

Re: Justified text displaying one character at a time

Post by darkfrei »

Thanks RNavega! It's awesome! Now I need to understand it and get how to do the DIY-justification.
:awesome: in Lua we Löve
:awesome: Platformer Guide
:awesome: freebies
RNavega
Party member
Posts: 251
Joined: Sun Aug 16, 2020 1:28 pm

Re: Justified text displaying one character at a time

Post by RNavega »

No problem!
If I had to choose, I think the best solution is the one that you were using, manipulating the string itself by adding each character in sequence. It works with both monospace and non-monospace fonts, and is also easier to modify in the future.

Technically, using a static Text object and doing the reveal effect in a shader might be a bit faster than changing the raw string and using it with love.graphics.print()/printf(), but the performance difference should be imperceptible, so it doesn't make a difference.

PS I noticed a bug in my code, you can see in that GIF how the first two characters from "An old silent pond", the "An" is revealed all at once, without it going through A then N.
It had to do with how the ray positions were being initialized. I fixed that and changed the attachment to v0.2.
User avatar
togFox
Party member
Posts: 779
Joined: Sat Jan 30, 2021 9:46 am
Location: Brisbane, Oztralia

Re: Justified text displaying one character at a time

Post by togFox »

** has limitations **

You can print the whole text to screen then draw a black box over the top so it obscures the text. You can then shrink the black box on a loop so that each character is revealed one at a time and has the same effect.

(you'd need two black boxes)

** has limitations **
Current project:
https://togfox.itch.io/backyard-gridiron-manager
American football manager/sim game - build and manage a roster and win season after season
Post Reply

Who is online

Users browsing this forum: Bing [Bot], Google [Bot] and 47 guests