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;
}
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;}
}
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);
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);
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;
}
And the fragment shader will look really simple now:
precision mediump float;
varying vec4 v_color;
void main() {
gl_FragColor = v_color;
}
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"]
];
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}
/>
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!
Top comments (0)