DEV Community

Cover image for Pixel Art Editor using WebGL
Valeria
Valeria

Posted on

Pixel Art Editor using WebGL

I was looking for a fun project to do and came up with this editor.

By the way, the project is tagged with #hacktoberfest, feel free to grab one of the existing issues or create a PR without one.

How the editor works

WebGL is a technology that allows HTMLCanvas to talk to GPUs directly via a language called GLSL. To put it simply, you provide WebGL context with coordinates, colours and two functions:

  • vertex shader, that tells where each vertex should be positioned
  • fragment shader, that tells what colour should every vertex or point on the screen have

If you're looking for an in-depth introduction to WebGL, check this article by Maxime Euzière, it's very good.

The editor I wrote uses two sets of shaders:

  • first one draws the pixel points on the screen
  • and the other draws a thin grid above it.

Drawing grid with WebGL

Vertex shader for grid simply sets position to the exact value it's fed with:

attribute vec4 position;
void main() {
  gl_Position = position;
}
Enter fullscreen mode Exit fullscreen mode

And the fragment shader discards all the pixels, apart from a thin interval around coordinates that are dividable by cell size:

precision mediump float;
uniform float size;

void main() {
  if(
   mod(gl_FragCoord.x,size)<1.0 ||
   mod(gl_FragCoord.y,size)<1.0
  ){
    gl_FragColor = vec4(0.0, 0.0, 0.0, 0.8);
  }else {discard;}                      
}
Enter fullscreen mode Exit fullscreen mode

These shaders are compiled like this:

const canvas = document.querySelector('canvas');
const gl = canvas.getContext('webgl');
// Compile vertex shader
const vs = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vs, vertexShader);
gl.compileShader(vs);

// Compile fragment shader
const fs = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fs, fragmentShader);
gl.compileShader(fs);

// Create and launch the WebGL program
const program = gl.createProgram();
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
Enter fullscreen mode Exit fullscreen mode

And can be used like this:

// Clear the canvas
gl.clear(gl.COLOR_BUFFER_BIT);
// Activate grid shaders
gl.useProgram(program);

// Set size value
const size = gl.getUniformLocation(program, 'size');
gl.uniform1f(size, 32); // Cell Size

// Four vertices represent corners of the canvas
// Each row is x,y,z coordinate
// -1,-1 is left bottom, z is always zero, since we draw in 2d
const vertices = new Float32Array([
 1.0, 1.0, 0.0, 
-1.0, 1.0, 0.0, 
 1.0, -1.0, 0.0, 
-1.0, -1.0, 0.0
]);

// Attach vertices to a buffer
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);

// Set position to point to buffer
const position = gl.getAttribLocation(program, 'position');

gl.vertexAttribPointer(
    position, // target
    3, // x,y,z
    gl.FLOAT, // type
    false, // normalize
    0, // buffer offset
    0 // buffer offset
);

gl.enableVertexAttribArray(position);

// Finally draw our 4 vertices
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
Enter fullscreen mode Exit fullscreen mode

Here's the result:

Drawing pixels on the grid

To draw square pixels we will simply set vertices to the dots we want to draw and set their size to 32- to match the grid.

Fragment shaders do not have access to the buffer itself, so in order to properly colour the points, we need to pass this colour information from the vertex shader:

attribute vec4 position;
attribute vec4 color;
varying vec4 v_color;
uniform float size;

void main() {
  gl_Position = position;
  v_color = color;
  gl_PointSize = size;
}
Enter fullscreen mode Exit fullscreen mode

And the fragment shader will look really simple now:

precision mediump float;
varying vec4 v_color;

void main() {
  gl_FragColor = v_color;
}
Enter fullscreen mode Exit fullscreen mode

With a couple of math functions we can turn this array:

const pixels = [
  [0, 0, "#ff0000"],
  [1, 1, "#ffaa00"],
  [2, 2, "#ffff00"],
  [3, 3, "#00ff00"],
  [4, 4, "#00ffaa"],
  [5, 5, "#00ffff"],
  [6, 6, "#0000ff"],
  [7, 7, "#ff00aa"],
  [8, 8, "#ff00ff"]
];
Enter fullscreen mode Exit fullscreen mode

Into this:

And the rest of the editor is svelte's magic:

<script lang="ts">
import { onMount } from 'svelte';
omMount(()=>{
  /** compile shaders **/
})
const render = ()=>{
 /** render stuff **/
}

const PIXEL_RATIO = window.devicePixelRatio;
let canvas: HTMLCanvasElement;
let gl: WebGLRenderingContext;

export let blockSize = 32;
export let size: number = 16;
// [x,y,color]
export let pixels: Array<[number, number, string]> = [];
export let color: string = '#ff0000';

const recordPoint = (x: number, y: number) => {
    pixels = pixels.filter(([px, py]) => x !== px || y !== py);
    if (color) {
    // Draw
    pixels.push([x, y, color]);
  }
};

const onClick = (e) => {
    const x = Math.floor(e.offsetX / blockSize * PIXEL_RATIO);
    const y = Math.floor(e.offsetY / blockSize * PIXEL_RATIO);
    recordPoint(x, y);
    render();
};

</script>

<canvas
    bind:this={canvas}
    width={size * blockSize}
    height={size * blockSize}
    style={`width:${(size * blockSize) / PIXEL_RATIO}px; height:${
        (size * blockSize) / PIXEL_RATIO
    }px;`}
    on:click={onClick}
/>

Enter fullscreen mode Exit fullscreen mode

Which boils down to this:

  • locate clicked cell coordinates
  • add new pixel with selected colour and coordinates

Why WebGL

Drawing static pictures with 2D canvas methods like fillRect and lineTo is quite easy, but is you need to redraw contents often it quickly becomes visibly slow.

And besides, once you get a grip of WebGL it's not much harder to write shaders than operate old-school canvas.

Afterword

Thank you for your time and I hope you found this article useful.

And don't be a stranger, join the development of the pixel-vg editor!

Discussion (0)