fast pixel by pixel rendering or better way ?

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.
Post Reply
User avatar
Bigfoot71
Party member
Posts: 287
Joined: Fri Mar 11, 2022 11:07 am

fast pixel by pixel rendering or better way ?

Post by Bigfoot71 »

Hello everyone !

To be brief, I'm trying to render an image distorted by a sinusoid but I realize that it's really slow (obviously since it's displayed pixel by pixel). So I looked for a way to render it "directly in a texture" and display it (much like in "pure SDL") but I couldn't find an equivalent in love. I tried other techniques with canvas but they were totally flawed...

Here is a mini code that I wrote as a demo, if someone can direct me to a more suitable solution.

I'm running around 30 FPS at home with this example.

Code: Select all

local WIN_W, WIN_H
local image = {}
local phase

function love.load()

    local imageData = love.image.newImageData("image.png")

    WIN_W, WIN_H = love.graphics.getDimensions()
    image.w, image.h = imageData:getDimensions()

    image.x = (WIN_W-image.w) / 2
    image.y = (WIN_H-image.h) / 2

    image.pixels = {}

    for x = 0, image.w - 1 do
        for y = 0, image.h - 1 do

            local r, g, b, a = imageData:getPixel(x, y)
            image.pixels[y * image.w + x] = {r, g, b, a}

        end
    end

    phase = 0

end

function love.update(dt)

    phase = phase + 5 * dt

end

function love.draw()

    love.graphics.print("FPS: "..tostring(love.timer.getFPS( )), 0, 0)

    -- Draw distorted image --

    for x = 0, image.w - 1 do
        for y = 0, image.h - 1 do

            local x_sin = 10 * math.sin(y * .05 + phase)

            love.graphics.setColor(
                image.pixels[y * image.w + x]
            )

            love.graphics.points(
                image.x + x + x_sin,
                image.y + y
            )

        end
    end

    love.graphics.setColor(1,1,1)

end
Attachments
Sinusoidal-Image-Distortion.love
(3.26 KiB) Downloaded 64 times
My avatar code for the curious :D V1, V2, V3.
User avatar
ReFreezed
Party member
Posts: 612
Joined: Sun Oct 25, 2015 11:32 pm
Location: Sweden
Contact:

Re: fast pixel by pixel rendering or better way ?

Post by ReFreezed »

This is exactly what the GPU and shaders are for.
Tools: Hot Particles, LuaPreprocess, InputField, (more) Games: Momento Temporis
"If each mistake being made is a new one, then progress is being made."
User avatar
pgimeno
Party member
Posts: 3544
Joined: Sun Oct 18, 2015 2:58 pm

Re: fast pixel by pixel rendering or better way ?

Post by pgimeno »

Indeed. Shaders work pixel by pixel and they can do what you're trying to accomplish very quickly.

Note that for shaders, you need to think in terms of how each pixel needs to be rendered, rather than in terms of where to draw each pixel. It's similar but the difference is important. In this case, it means that you have to sample each pixel from a position that is distorted by a sine wave for it to render in the right place. You may also need to draw a bigger area than that covered by the image, because the horizontal distortion makes some pixels go out of the image's rectangle, and if you want to draw these, you need the drawn objects to cover them (note: you can't use love.graphics.rectangle because that one doesn't have proper UVs).

Remember also that the texture coordinates received by the shader are normalized, that is, they range from 0 for the left side of the leftmost pixel, to 1 for the right side of the rightmost pixel, and same vertically. The shader doesn't know about texture size; if you need it, you have to pass it as a uniform.

Adjusting the scale properly was a bit tricky, but here's exactly the same effect as yours, implemented as a shader:

Code: Select all

local image, sineShader, phase, WIN_W, WIN_H

function love.load()
  image = {}
  image.texture = love.graphics.newImage('image.png')
  image.texture:setWrap("clampzero", "clampzero")

  WIN_W, WIN_H = love.graphics.getDimensions()
  image.w, image.h = image.texture:getDimensions()

  image.x = (WIN_W-image.w) / 2
  image.y = (WIN_H-image.h) / 2

  sineShader = love.graphics.newShader[[

    uniform vec2 imgSize;
    uniform float phase;

    vec4 effect(vec4 colour, Image tex, vec2 texPos, vec2 scrPos)
    {
      vec2 samplePos;
      samplePos.x = texPos.x * ((imgSize.x + 20.0) / imgSize.x);
      float sinScale = 10.0 / imgSize.x;
      samplePos.x -= sinScale * (1.0 + sin(phase + 0.05*imgSize.y*texPos.y));
      samplePos.y = texPos.y;
      vec4 sample = Texel(tex, samplePos);
      return sample * colour;
    }

  ]]
  sineShader:send('imgSize', {image.w, image.h})
  phase = 0
end

function love.update(dt)
  phase = phase + 5 * dt
end

function love.draw()
  love.graphics.setShader(sineShader)
  sineShader:send("phase", phase)
  love.graphics.draw(image.texture, image.x - 10, image.y, 0,
    (image.w + 20)/image.w, 1)
  love.graphics.setShader()

  love.graphics.print("FPS: "..tostring(love.timer.getFPS( )), 0, 0)
end
Edit: Note how I subtracted the sine rather than adding it; had I not done that, the effect wouldn't match yours. That's because of what I mentioned about writing it in terms of what each pixel needs to contain, rather than where to render each pixel.
Last edited by pgimeno on Wed Sep 14, 2022 12:58 pm, edited 1 time in total.
User avatar
ReFreezed
Party member
Posts: 612
Joined: Sun Oct 25, 2015 11:32 pm
Location: Sweden
Contact:

Re: fast pixel by pixel rendering or better way ?

Post by ReFreezed »

This is basically the same as pgimeno's solution, but I spent time writing it so I'm posting it anyway. :|

Code: Select all

local WAVE_AMPLITUDE = 10  -- pixels
local WAVE_LENGTH    = 100 -- pixels
local WAVE_INTERVAL  = 1.5 -- seconds

local image = love.graphics.newImage("image.png")
image:setWrap("clampzero") -- Don't repeat edge pixels.

local shader = love.graphics.newShader[[
	uniform float waveAmplitude;
	uniform float waveLength;
	uniform float wavePhase;
	uniform float padding;
	uniform float scale;

	#define TAU 6.283185307179586 // 2*pi

	vec4 effect(vec4 loveColor, Image tex, vec2 texUv, vec2 screenPos) {
		texUv.x  = texUv.x * scale - padding; // Undo :ScaleImage and :ScalePosition.
		texUv.x += waveAmplitude * sin((texUv.y / waveLength + wavePhase) * TAU); // Add wave.

		vec4 pixel = Texel(tex, texUv);
		pixel.a    = max(pixel.a, .5); // DEBUG: Show the bounds of the rendered rectangle.

		return pixel * loveColor;
	}
]]

function love.draw()
	local ww, wh = love.graphics.getDimensions()
	local iw, ih = image:getDimensions()

	local wavePhase = love.timer.getTime() / WAVE_INTERVAL
	local padding   = WAVE_AMPLITUDE -- Not adding padding would result in the texture getting cut off. Try setting this to 0!
	local scale     = 1 + 2*padding/iw

	shader:send("waveAmplitude", WAVE_AMPLITUDE/iw) -- Remember: Shaders use normalized texture coordinates.
	shader:send("waveLength", WAVE_LENGTH/ih)
	shader:send("wavePhase", wavePhase)
	shader:send("padding", padding/iw)
	shader:send("scale", scale)

	local x = (ww - iw*scale) / 2 -- :ScalePosition
	local y = (wh - ih      ) / 2

	love.graphics.clear(.3, .3, .3)
	love.graphics.setShader(shader)
	love.graphics.draw(image, x,y, 0, scale,1) -- :ScaleImage
end
Tools: Hot Particles, LuaPreprocess, InputField, (more) Games: Momento Temporis
"If each mistake being made is a new one, then progress is being made."
User avatar
Bigfoot71
Party member
Posts: 287
Joined: Fri Mar 11, 2022 11:07 am

Re: fast pixel by pixel rendering or better way ?

Post by Bigfoot71 »

Thanks a lot ! I had just started to read docs to do it and publish it here, I'm going to try to understand your codes to know how to reproduce it myself. I'm especially trying to learn, I didn't expect so much ^^
My avatar code for the curious :D V1, V2, V3.
User avatar
milon
Party member
Posts: 472
Joined: Thu Jan 18, 2018 9:14 pm

Re: fast pixel by pixel rendering or better way ?

Post by milon »

Bigfoot71 wrote: Wed Sep 14, 2022 12:51 pm I'm especially trying to learn, I didn't expect so much ^^
Yup, awesome people here! I lӧve this community for that reason.
Any code samples/ideas by me should be considered Public Domain (no attribution needed) license unless otherwise stated.
User avatar
pgimeno
Party member
Posts: 3544
Joined: Sun Oct 18, 2015 2:58 pm

Re: fast pixel by pixel rendering or better way ?

Post by pgimeno »

Here's the same idea but without the adjustments to make it fit, which is clearer and easier to understand:

Code: Select all

local image, sineShader, phase, WIN_W, WIN_H

function love.load()
  image = {}
  image.texture = love.graphics.newImage('image.png')
  image.texture:setWrap("clampzero", "clampzero")

  WIN_W, WIN_H = love.graphics.getDimensions()
  image.w, image.h = image.texture:getDimensions()

  image.x = (WIN_W-image.w) / 2
  image.y = (WIN_H-image.h) / 2

  sineShader = love.graphics.newShader[[

    uniform vec2 imgSize;
    uniform float phase;

    vec4 effect(vec4 colour, Image tex, vec2 texPos, vec2 scrPos)
    {
      vec2 samplePos;
      samplePos.x = texPos.x - (10.0 / imgSize.x) * sin(phase + 0.05*imgSize.y*texPos.y);
      samplePos.y = texPos.y;
      vec4 sample = Texel(tex, samplePos);
      return sample * colour;
    }

  ]]
  sineShader:send('imgSize', {image.w, image.h})
  phase = 0
end

function love.update(dt)
  phase = phase + 5 * dt
end

function love.draw()
  love.graphics.setShader(sineShader)
  sineShader:send("phase", phase)
  love.graphics.draw(image.texture, image.x, image.y)
  love.graphics.setShader()

  love.graphics.print("FPS: "..tostring(love.timer.getFPS( )), 0, 0)
end
If you run it, you'll notice that it goes out of the image frame while waving; that's the reason for the other version, although this one is easier to understand. A simpler fix than the adjustments in the previous version would be to make the image bigger, so that it includes enough margin for waving without going out of the frame.

Note the following:
  • 10.0 is the number of pixels to displace the texture, at the cusp of the sine wave. It needs to be divided by imgSize.x in order to get a normalized coordinate from 0.0 to 1.0, because these are the units used by texPos and by the Texel function.
  • Since texPos.y is normalized, in order to match your function I had to multiply it by the texture height (imgSize.y) to obtain the position in pixels, which is what you were using for the angle of the sine.
  • In the `return sample * colour;` line, `colour` is there just to allow for tinting; if you don't want to allow tinting you can just return `sample`.
  • `extern` is a deprecated alias for `uniform`. Not sure why it was deprecated, but it's much clearer what it does if you change `uniform` to `extern`: it's used for the external variables that are passed via Shader:send.
User avatar
milon
Party member
Posts: 472
Joined: Thu Jan 18, 2018 9:14 pm

Re: fast pixel by pixel rendering or better way ?

Post by milon »

pgimeno wrote: Thu Sep 15, 2022 11:58 am 10.0 is the number of pixels to displace the texture, at the cusp of the sine wave. It needs to be divided by imgSize.x in order to get a normalized coordinate from 0.0 to 1.0, because these are the units used by texPos and by the Texel function.
One other thing to note: Shaders are very picky about number type. In shader code, 10 and 10.0 are NOT the same thing. 10 is an integer and therefore the shader will attempt to perform integer math. 10.0 is a floating point number and the shader will use floating point math operations instead. If this is new information to you, you can read up more on that in several places such as Stack Overflow.
Any code samples/ideas by me should be considered Public Domain (no attribution needed) license unless otherwise stated.
User avatar
pgimeno
Party member
Posts: 3544
Joined: Sun Oct 18, 2015 2:58 pm

Re: fast pixel by pixel rendering or better way ?

Post by pgimeno »

Indeed, sorry for leaving that information out - I thought it would not be immediately relevant for someone who is learning about shaders.

Typical desktop OpenGL drivers don't have any problem compiling a shader that includes an integer where a float is expected, because automatic type casting is part of the language specification. However, OpenGL ES (mostly used in mobile phones) does not support automatic type casting, and the shader will fail to compile in devices that lack that extension, which is most mobile phones.

I believe that on regular GL (not GLES), using 10 instead of 10.0 will work just fine in this case, because one of the types in the division is a float, so the other operand is automatically cast to float and then everything works as expected (yep, tried, it works for me). In GLES you will get an error instead (tried by setting the environment variable LOVE_GRAPHICS_USE_OPENGLES=1).
Post Reply

Who is online

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