Responsive canvas using webgl and meatballs.js

Viewed 417

I'm attempting to implement this codepen as a background of my personal website. I have no real knowledge of WebGL, so please bear with me. I temporarily added an event listener to update the width and height of the canvas when the page is resized. I can tell this works because when the bubbles start going out of bounds, they continue going and don't bounce off the edge of the page, so I know it somewhat works the way I want it to. When the fragment shader source is defined it also defines the width and height and I'm not sure how to change those variables after that. I tried redefining, recompiling, and reattaching the fragment shader source with the new widths and heights. This obviously doesn't work because the bubbles do not render past the size of the page when the canvas was created. I'm not sure if i'm even going about this the right way, if so what am I doing wrong? All/any help is appreciated, thank you.

The code I changed:

var canvas = document.createElement("canvas");
var width = canvas.width = window.innerWidth * 0.75;
var height = canvas.height = window.innerHeight * 0.75;
document.body.appendChild(canvas);
var gl = canvas.getContext('webgl');

var mouse = {x: 0, y: 0};

var numMetaballs = 30;
var metaballs = [];

var first = true

window.addEventListener('resize', function(){
    width = canvas.width = window.innerWidth * 0.75;
    height = canvas.height = window.innerHeight * 0.75;
    shaderStuff()
})

function shaderStuff(){
    if(!first) {
        gl.detachShader(program, gl.getAttachedShaders(program)[1])
    }
    first = false
    
    var fragmentShaderSrc = `
    precision highp float;

    const float WIDTH = ` + (width >> 0) + `.0;
    const float HEIGHT = ` + (height >> 0) + `.0;

    uniform vec3 metaballs[` + numMetaballs + `];

    void main(){
    float x = gl_FragCoord.x;
    float y = gl_FragCoord.y;

    float sum = 0.0;
    for (int i = 0; i < ` + numMetaballs + `; i++) {
    vec3 metaball = metaballs[i];
    float dx = metaball.x - x;
    float dy = metaball.y - y;
    float radius = metaball.z;

    sum += (radius * radius) / (dx * dx + dy * dy);
    }

    if (sum >= 0.99) {
    gl_FragColor = vec4(mix(vec3(x / WIDTH, y / HEIGHT, 1.0), vec3(0, 0, 0), max(0.0, 1.0 - (sum - 0.99) * 100.0)), 1.0);
    return;
    }

    gl_FragColor = vec4(0, 0, 0, 0);
    }

    `;
    
    var fragmentShader = compileShader(fragmentShaderSrc, gl.FRAGMENT_SHADER);
    gl.attachShader(program, fragmentShader);
}

for (var i = 0; i < numMetaballs; i++) {
  var radius = Math.random() * 60 + 10;
  metaballs.push({
    x: Math.random() * (width - 2 * radius) + radius,
    y: Math.random() * (height - 2 * radius) + radius,
    vx: (Math.random() - 0.5) * 3,
    vy: (Math.random() - 0.5) * 3,
    r: radius * 0.75
  });
}

var vertexShaderSrc = `
attribute vec2 position;

void main() {
// position specifies only x and y.
// We set z to be 0.0, and w to be 1.0
gl_Position = vec4(position, 0.0, 1.0);
}
`;



var vertexShader = compileShader(vertexShaderSrc, gl.VERTEX_SHADER);


var program = gl.createProgram();
gl.attachShader(program, vertexShader);
shaderStuff()
gl.linkProgram(program);
gl.useProgram(program);

The whole project https://meatballsjs.000webhostapp.com/

The original https://codepen.io/TC5550/pen/WNNWoaO

2 Answers

The easiest way would be to put all of the background creation code in a function, and call it every time the page is resized.

You will also need to add some code to cause the previous background loops to stop, and you should add some throttling to prevent too many backgrounds to be created at once.

This is somewhat inefficient, but most users don't expect applications to be extremely responsive while they are being resized, and resizing is an infrequent operation.

I added a code snippet, which appears to work, however I could not get my changes to work in codepen. I believe this is because codepen instruments and modifies the code in a certain way that breaks it (jsbin has similar behavior to prevent infinite loops, and to sandbox it). However I tested my changes in just a .html file, and they seemed to work there, so they should work on your site.

On a side note, very cool use of WebGL!

var nextBackgroundId = 1;
var currentBackgroundId = 0;

setupBackground(currentBackgroundId);
window.addEventListener("resize", () => {
    var ourBackgroundId = nextBackgroundId++;
    currentBackgroundId = ourBackgroundId;
    setTimeout(() => {
        setupBackground(ourBackgroundId);
    }, 100);
});

function setupBackground(ourBackgroundId) {
    if (currentBackgroundId !== ourBackgroundId) {
        return;
    }

    var prevCanvas = document.getElementById("blob-canvas");
    if (prevCanvas) {
        prevCanvas.remove();
    }

    var canvas = document.createElement("canvas");
    canvas.id = "blob-canvas";
    var mouse = { x: 0, y: 0 };

    canvas.onmousemove = function (e) {
        mouse.x = e.clientX;
        mouse.y = e.clientY;
    }

    var width = canvas.width = window.innerWidth;
    var height = canvas.height = window.innerHeight;
    document.body.appendChild(canvas);
    var gl = canvas.getContext('webgl');

    var numMetaballs = 30;
    var metaballs = [];

    for (var i = 0; i < numMetaballs; i++) {
        var radius = Math.random() * 60 + 10;
        metaballs.push({
            x: Math.random() * (width - 2 * radius) + radius,
            y: Math.random() * (height - 2 * radius) + radius,
            vx: (Math.random() - 0.5) * 3,
            vy: (Math.random() - 0.5) * 3,
            r: radius * 0.75
        });
    }

    var vertexShaderSrc = `
attribute vec2 position;

void main() {
// position specifies only x and y.
// We set z to be 0.0, and w to be 1.0
gl_Position = vec4(position, 0.0, 1.0);
}
`;

    var fragmentShaderSrc = `
precision highp float;

const float WIDTH = ` + (width >> 0) + `.0;
const float HEIGHT = ` + (height >> 0) + `.0;

uniform vec3 metaballs[` + numMetaballs + `];

void main(){
float x = gl_FragCoord.x;
float y = gl_FragCoord.y;

float sum = 0.0;
for (int i = 0; i < ` + numMetaballs + `; i++) {
vec3 metaball = metaballs[i];
float dx = metaball.x - x;
float dy = metaball.y - y;
float radius = metaball.z;

sum += (radius * radius) / (dx * dx + dy * dy);
}

if (sum >= 0.99) {
gl_FragColor = vec4(mix(vec3(x / WIDTH, y / HEIGHT, 1.0), vec3(0, 0, 0), max(0.0, 1.0 - (sum - 0.99) * 100.0)), 1.0);
return;
}

gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
}

`;

    var vertexShader = compileShader(vertexShaderSrc, gl.VERTEX_SHADER);
    var fragmentShader = compileShader(fragmentShaderSrc, gl.FRAGMENT_SHADER);

    var program = gl.createProgram();
    gl.attachShader(program, vertexShader);
    gl.attachShader(program, fragmentShader);
    gl.linkProgram(program);
    gl.useProgram(program);

    var vertexData = new Float32Array([
        -1.0, 1.0, // top left
        -1.0, -1.0, // bottom left
        1.0, 1.0, // top right
        1.0, -1.0, // bottom right
    ]);
    var vertexDataBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, vertexDataBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, vertexData, gl.STATIC_DRAW);

    var positionHandle = getAttribLocation(program, 'position');
    gl.enableVertexAttribArray(positionHandle);
    gl.vertexAttribPointer(positionHandle,
        2, // position is a vec2
        gl.FLOAT, // each component is a float
        gl.FALSE, // don't normalize values
        2 * 4, // two 4 byte float components per vertex
        0 // offset into each span of vertex data
    );

    var metaballsHandle = getUniformLocation(program, 'metaballs');

    loop();
    function loop() {
        if (currentBackgroundId !== ourBackgroundId) {
            return;
        }
        for (var i = 0; i < numMetaballs; i++) {
            var metaball = metaballs[i];
            metaball.x += metaball.vx;
            metaball.y += metaball.vy;

            if (metaball.x < metaball.r || metaball.x > width - metaball.r) metaball.vx *= -1;
            if (metaball.y < metaball.r || metaball.y > height - metaball.r) metaball.vy *= -1;
        }

        var dataToSendToGPU = new Float32Array(3 * numMetaballs);
        for (var i = 0; i < numMetaballs; i++) {
            var baseIndex = 3 * i;
            var mb = metaballs[i];
            dataToSendToGPU[baseIndex + 0] = mb.x;
            dataToSendToGPU[baseIndex + 1] = mb.y;
            dataToSendToGPU[baseIndex + 2] = mb.r;
        }
        gl.uniform3fv(metaballsHandle, dataToSendToGPU);

        //Draw
        gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

        requestAnimationFrame(loop);
    }

    function compileShader(shaderSource, shaderType) {
        var shader = gl.createShader(shaderType);
        gl.shaderSource(shader, shaderSource);
        gl.compileShader(shader);

        if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
            throw "Shader compile failed with: " + gl.getShaderInfoLog(shader);
        }

        return shader;
    }

    function getUniformLocation(program, name) {
        var uniformLocation = gl.getUniformLocation(program, name);
        if (uniformLocation === -1) {
            throw 'Can not find uniform ' + name + '.';
        }
        return uniformLocation;
    }

    function getAttribLocation(program, name) {
        var attributeLocation = gl.getAttribLocation(program, name);
        if (attributeLocation === -1) {
            throw 'Can not find attribute ' + name + '.';
        }
        return attributeLocation;
    }
}
body {
    font-family: 'Alatsi', sans-serif;
    margin: 0;
    overflow: hidden;
    background: black;
}

.container {
    display: flex;
    justify-content: center;
    align-items: center;

    position: absolute;
    top: 0;
    left: 0;
    width: 100vw;
    height: 100vh;
}

.title {
    font-size: 10vw;
    color: white;
}

canvas {
    width: 100%;
}
<div class="container">
    <span class="title">MEATBALLS</span>
</div>

There are a lot of issues with that codepen.

  1. It's hacking the canvas size instead of letting CSS size the canvas.

    In the code the canvas size is set with

    var width = canvas.width = window.innerWidth * 0.75;
    var height = canvas.height = window.innerHeight * 0.75;
    

    It's arguably best to let the browser size the canvas

    html, body {
      height: 100%;
      overflow: hidden;
    }     
    canvas {
      width: 100%;
      height: 100%;
    }
    

    and then ask the browser what size the canvas is and set the canvas's resolution to match

    canvas.width = canvas.clientWidth;
    canvas.height = canvas.clientHeight;
    
  2. It's asking for things are larger than the window so it would get a scrollbar and then hiding the that fact by hiding the scrollbar. It makes no sense. If you don't want a scrollbar don't ask for content that requires a scrollbar.

    html, body {
      height: 100%;
      /* removed overflow: hidden */
    }     
    canvas {
      width: 100%;
      height: 100%;
      display: block;
    }
    
  3. It's using template strings but not actually using them as templates

    var fragmentShaderSrc = `
    precision highp float;
    
    const float WIDTH = ` + (width >> 0) + `.0;
    const float HEIGHT = ` + (height >> 0) + `.0;
    
    uniform vec3 metaballs[` + numMetaballs + `];
    ...
    `;
    

    should arguably be

    var fragmentShaderSrc = `
    precision highp float;
    
    const float WIDTH = ${width >> 0}.0;
    const float HEIGHT = ${height >> 0}.0;
    
    uniform vec3 metaballs[${numMetaballs}];
    ...
    `;
    

    The main point of using backticks for strings is so you can use the templating feature ${code}

  4. It's hard coding the width and height

    const float WIDTH = ${width >> 0}.0;
    const float HEIGHT = ${height >> 0}.0;
    

    should arguably be

    uniform float WIDTH;
    uniform float HEIGHT;
    

    so they can be set

  5. Metaballs is misspelled as Meatballs (maybe that was intentional)

Here's a new version. Note: anytime the window is resized the metaballs get a random position. If you comment out the call to updateMetaballs after resizing the canvas then they will not get new random positions. Which is better is up to you. The logic for how they bounce is such that any balls that are off the screen after you resize will stay off the screen. You could fix it so they'll head toward the screen and only bounce from the inside back in. The current code is such that on the outside they'll just wobble where they are.

var canvas = document.createElement("canvas");
document.body.appendChild(canvas);
var gl = canvas.getContext('webgl');

var mouse = {x: 0, y: 0};

var numMetaballs = 30;
var metaballs = [];

function updateMetaballs() {
  const width = canvas.clientWidth;
  const height = canvas.clientHeight;
  for (var i = 0; i < numMetaballs; i++) {
    var radius = Math.random() * 60 + 10;
    metaballs[i] = {
      x: Math.random() * (width - 2 * radius) + radius,
      y: Math.random() * (height - 2 * radius) + radius,
      vx: (Math.random() - 0.5) * 3,
      vy: (Math.random() - 0.5) * 3,
      r: radius * 0.75
    };
  }
}
updateMetaballs();

var vertexShaderSrc = `
attribute vec2 position;

void main() {
  // position specifies only x and y.
  // We set z to be 0.0, and w to be 1.0
  gl_Position = vec4(position, 0.0, 1.0);
}
`;

var fragmentShaderSrc = `
precision highp float;

uniform float WIDTH;
uniform float HEIGHT;

#define NUM_METABALLS ${numMetaballs}
uniform vec3 metaballs[NUM_METABALLS];

void main(){
  float x = gl_FragCoord.x;
  float y = gl_FragCoord.y;

  float sum = 0.0;
  for (int i = 0; i < NUM_METABALLS; i++) {
    vec3 metaball = metaballs[i];
    float dx = metaball.x - x;
    float dy = metaball.y - y;
    float radius = metaball.z;

    sum += (radius * radius) / (dx * dx + dy * dy);
  }

  if (sum >= 0.99) {
    gl_FragColor = vec4(mix(vec3(x / WIDTH, y / HEIGHT, 1.0), vec3(0, 0, 0), max(0.0, 1.0 - (sum - 0.99) * 100.0)), 1.0);
    return;
  }

  gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
}

`;

var vertexShader = compileShader(vertexShaderSrc, gl.VERTEX_SHADER);
var fragmentShader = compileShader(fragmentShaderSrc, gl.FRAGMENT_SHADER);

var program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.useProgram(program);

var vertexData = new Float32Array([
  -1.0,  1.0, // top left
  -1.0, -1.0, // bottom left
  1.0,  1.0, // top right
  1.0, -1.0, // bottom right
]);
var vertexDataBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexDataBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertexData, gl.STATIC_DRAW);

var positionHandle = getAttribLocation(program, 'position');
gl.enableVertexAttribArray(positionHandle);
gl.vertexAttribPointer(positionHandle,
                       2, // position is a vec2
                       gl.FLOAT, // each component is a float
                       gl.FALSE, // don't normalize values
                       2 * 4, // two 4 byte float components per vertex
                       0 // offset into each span of vertex data
                      );

var metaballsHandle = getUniformLocation(program, 'metaballs');
var widthHandle = getUniformLocation(program, 'WIDTH');
var heightHandle = getUniformLocation(program, 'HEIGHT');

function resizeCanvasToDisplaySize(canvas) {
  const width = canvas.clientWidth;
  const height = canvas.clientHeight;
  const needResize = canvas.width !== width || canvas.height !== height;
  if (needResize) {
    canvas.width = width;
    canvas.height = height;
  }
  return needResize;
}

loop();
function loop() {
  if (resizeCanvasToDisplaySize(canvas)) {
    updateMetaballs();
  }
  const {width, height} = canvas;
  gl.viewport(0, 0, canvas.width, canvas.height);
  
  for (var i = 0; i < numMetaballs; i++) {
    var metaball = metaballs[i];
    metaball.x += metaball.vx;
    metaball.y += metaball.vy;

    if (metaball.x < metaball.r || metaball.x > width - metaball.r) metaball.vx *= -1;
    if (metaball.y < metaball.r || metaball.y > height - metaball.r) metaball.vy *= -1;
  }

  var dataToSendToGPU = new Float32Array(3 * numMetaballs);
  for (var i = 0; i < numMetaballs; i++) {
    var baseIndex = 3 * i;
    var mb = metaballs[i];
    dataToSendToGPU[baseIndex + 0] = mb.x;
    dataToSendToGPU[baseIndex + 1] = mb.y;
    dataToSendToGPU[baseIndex + 2] = mb.r;
  }
  gl.uniform3fv(metaballsHandle, dataToSendToGPU);
  gl.uniform1f(widthHandle, canvas.clientWidth);
  gl.uniform1f(heightHandle, canvas.clientHeight);
  
  //Draw
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

  requestAnimationFrame(loop);
}

function compileShader(shaderSource, shaderType) {
  var shader = gl.createShader(shaderType);
  gl.shaderSource(shader, shaderSource);
  gl.compileShader(shader);

  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    throw "Shader compile failed with: " + gl.getShaderInfoLog(shader);
  }

  return shader;
}

function getUniformLocation(program, name) {
  var uniformLocation = gl.getUniformLocation(program, name);
  if (uniformLocation === -1) {
    throw 'Can not find uniform ' + name + '.';
  }
  return uniformLocation;
}

function getAttribLocation(program, name) {
  var attributeLocation = gl.getAttribLocation(program, name);
  if (attributeLocation === -1) {
    throw 'Can not find attribute ' + name + '.';
  }
  return attributeLocation;
}

canvas.onmousemove = function(e) {
  mouse.x = e.clientX;
  mouse.y = e.clientY;
}
html, body {
  font-family: 'Alatsi', sans-serif;
  margin: 0;
  background: black;
  height: 100%;
}

.container {
  display: flex;
  justify-content: center;
  align-items: center;
  
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

.title {
  font-size: 10vw;
  color: white;
}

canvas {
  width: 100%;
  height: 100%;
  display: block;
}
<div class="container">
    <span class="title">METABALLS</span>
</div>

If you want to learn WebGL consider these tutorials

Related