Created
February 6, 2021 00:44
-
-
Save jordanranson/cff865c40d5c00c4f227c8e714ce4eff to your computer and use it in GitHub Desktop.
2D WebGL Pixel Renderer (MIT License)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* eslint-disable */ | |
function paletteFromImage (src) { | |
return new Promise((resolve, reject) => { | |
const image = new Image() | |
image.onload = () => { | |
const canvas = document.createElement('canvas') | |
canvas.width = 16 | |
canvas.height = 16 | |
const context = canvas.getContext('2d') | |
context.drawImage(image, 0, 0) | |
const imageData = context.getImageData(0, 0, 16, 16) | |
resolve(imageData.data) | |
} | |
image.onerror = (err) => reject(err) | |
image.src = src | |
}) | |
} | |
const DrawApi = { | |
clear (color = 0) { | |
color = Math.round(color) | |
this.screenBuffer.fill(color) | |
}, | |
pixel (x, y, color) { | |
x = Math.round(x) | |
y = Math.round(y) | |
color = Math.round(color) | |
this.setPixel(x, y, color) | |
}, | |
noise (min = 0, max = 255) { | |
if (min instanceof Array) { | |
const colors = min | |
for (let i = 0; i < this.width * this.height; i++) { | |
this.screenBuffer[i] = Math.round(colors[Math.floor(Math.random() * colors.length)]) | |
} | |
} else { | |
min = Math.round(min) | |
max = Math.round(max) | |
max += 1 | |
for (let i = 0; i < this.width * this.height; i++) { | |
this.screenBuffer[i] = Math.floor(Math.random() * (max - min) + min) | |
} | |
} | |
}, | |
rect (x0, y0, x1, y1, color) { | |
color = Math.round(color) | |
const xa = Math.round(Math.min(x0, x1)) | |
const ya = Math.round(Math.min(y0, y1)) | |
const xb = Math.round(Math.max(x0, x1)) | |
const yb = Math.round(Math.max(y0, y1)) | |
let w = xb - xa + 1 | |
let h = yb - ya | |
this.setRow(xa, ya, w, color) | |
this.setRow(xa, yb, w, color) | |
while (h >= 0) { | |
this.setPixel(xa, ya + h, color) | |
this.setPixel(xb, ya + h, color) | |
h-- | |
} | |
}, | |
rectFill (x0, y0, x1, y1, color) { | |
color = Math.round(color) | |
const xa = Math.round(Math.min(x0, x1)) | |
const ya = Math.round(Math.min(y0, y1)) | |
const xb = Math.round(Math.max(x0, x1)) | |
const yb = Math.round(Math.max(y0, y1)) | |
let w = xb - xa + 1 | |
let h = yb - ya | |
while (h >= 0) { | |
this.setRow(xa, ya + h, w, color) | |
h-- | |
} | |
}, | |
circ (cx, cy, radius, color) { | |
cx = Math.round(cx) | |
cy = Math.round(cy) | |
radius = Math.round(radius) | |
color = Math.round(color) | |
let x = radius | |
let y = 0 | |
let radiusError = 1 - x | |
while (x >= y) { | |
this.setPixel(x + cx, y + cy, color) | |
this.setPixel(y + cx, x + cy, color) | |
this.setPixel(-x + cx, y + cy, color) | |
this.setPixel(-y + cx, x + cy, color) | |
this.setPixel(-x + cx, -y + cy, color) | |
this.setPixel(-y + cx, -x + cy, color) | |
this.setPixel(x + cx, -y + cy, color) | |
this.setPixel(y + cx, -x + cy, color) | |
y++ | |
if (radiusError < 0) { | |
radiusError += 2 * y + 1 | |
} else { | |
x-- | |
radiusError += 2 * (y - x + 1) | |
} | |
} | |
}, | |
circFill (cx, cy, radius, color) { | |
cx = Math.round(cx) | |
cy = Math.round(cy) | |
radius = Math.round(radius) | |
color = Math.round(color) | |
for (let y = - radius; y <= radius; y++) | |
for (let x = - radius; x <= radius; x++) | |
if (x * x + y * y <= radius * radius) | |
this.setPixel(cx + x, cy + y, color) | |
DrawApi.circ.call(this, cx, cy, radius, color) | |
}, | |
line (x0, y0, x1, y1, color) { | |
x0 = Math.round(x0) | |
y0 = Math.round(y0) | |
x1 = Math.round(x1) | |
y1 = Math.round(y1) | |
color = Math.round(color) | |
const dx = Math.abs(x1 - x0) | |
const dy = Math.abs(y1 - y0) | |
const sx = (x0 < x1) ? 1 : -1 | |
const sy = (y0 < y1) ? 1 : -1 | |
let err = dx - dy | |
while (true) { | |
this.setPixel(x0, y0, color) | |
if ((x0 === x1) && (y0 === y1)) break | |
const e2 = 2 * err | |
if (e2 > -dy) { | |
err -= dy | |
x0 += sx | |
} | |
if (e2 < dx) { | |
err += dx | |
y0 += sy | |
} | |
} | |
} | |
} | |
class PixelRenderer { | |
get defaultVertexShader () { | |
return ` | |
attribute vec2 aVertexPosition; | |
attribute vec2 aTextureCoord; | |
varying highp vec2 vTextureCoord; | |
void main() { | |
gl_Position = vec4(aVertexPosition, 0.0, 1.0); | |
vTextureCoord = aTextureCoord; | |
} | |
` | |
} | |
get defaultFragmentShader () { | |
return ` | |
precision highp float; | |
varying highp vec2 vTextureCoord; | |
uniform sampler2D uScreenSampler; | |
uniform sampler2D uPaletteSampler; | |
void main () { | |
float i = floor(texture2D(uScreenSampler, vTextureCoord).a*255.0); | |
float x = mod(i, 16.0); | |
float y = floor(i/16.0); | |
vec4 color = texture2D(uPaletteSampler, vec2(x/15.0, y/15.0)); | |
gl_FragColor = color; | |
} | |
` | |
} | |
get width () { | |
return this.glCanvas.width | |
} | |
get height () { | |
return this.glCanvas.height | |
} | |
get palette () { | |
return this.paletteCache | |
} | |
set palette (value) { | |
this.paletteCache = value | |
this.createPaletteBuffer() | |
} | |
constructor () { | |
this.vertexShader = this.defaultVertexShader; | |
this.fragmentShader = this.defaultFragmentShader; | |
this.handlers = [] | |
this.animationFrameId = null | |
this.canvas = undefined | |
this.context = undefined | |
} | |
run () { | |
cancelAnimationFrame(this.animationFrameId) | |
const drawApi = Object.keys(DrawApi).reduce((acc, fnName) => { | |
acc[fnName] = DrawApi[fnName].bind(this) | |
return acc | |
}, {}) | |
const renderLoop = () => { | |
this.animationFrameId = requestAnimationFrame(renderLoop) | |
for (const handler of this.handlers) handler.fn(drawApi, this.canvas, this.gl) | |
this.render() | |
} | |
renderLoop() | |
} | |
render () { | |
const { gl } = this | |
// Clear for redrawing | |
gl.clear(gl.COLOR_BUFFER_BIT) | |
// Update array buffers | |
this.setArrayBuffer(2, 'aVertexPosition', this.screenSurface.position) | |
this.setArrayBuffer(2, 'aTextureCoord', this.screenSurface.texture) | |
// Set shader uniforms | |
this.setUniform('1i', 'uScreenSampler', 0) | |
this.setUniform('1i', 'uPaletteSampler', 2) | |
// Bind the screen and palette textures | |
this.setScreenTexture(this.width, this.height, this.screenBuffer) | |
this.setPaletteTexture(16, 16, this.paletteBuffer) | |
// Draw surface | |
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4) | |
} | |
resize (width, height, scale) { | |
this.glCanvas.width = width | |
this.glCanvas.height = height | |
this.glCanvas.style.transform = `scale(${scale})` | |
this.glCanvas.style.imageRendering = `pixelated` | |
this.createScreenTexture() | |
this.gl.viewport(0, 0, width, height) | |
} | |
setPixel (x, y, color) { | |
if (x < 0 || y < 0 || x >= this.width || y >= this.height) return -1 | |
const i = x + (y * this.width) | |
this.screenBuffer[i] = color | |
} | |
setRow (x, y, w, color) { | |
if (x < 0 || y < 0 || x + w >= this.width || y >= this.height) return -1 | |
const i = x + (y * this.width) | |
this.screenBuffer.fill(color, i, i + w) | |
} | |
getPixel (x, y) { | |
const i = x + (y * this.width) | |
return this.screenBuffer[i] | |
} | |
// Texture | |
setTexture (texture, width, height, pixels, format) { | |
const { gl } = this | |
format = format || gl.ALPHA | |
gl.bindTexture(gl.TEXTURE_2D, texture) | |
gl.texImage2D(gl.TEXTURE_2D, 0, format, width, height, 0, format, gl.UNSIGNED_BYTE, pixels) | |
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) | |
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) | |
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) | |
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) | |
} | |
// Screen | |
createScreenSurface () { | |
const position = this.createArrayBuffer([ | |
-1.0, 1.0, | |
1.0, 1.0, | |
-1.0, -1.0, | |
1.0, -1.0 | |
]) | |
const texture = this.createArrayBuffer([ | |
0.0, 0.0, | |
1.0, 0.0, | |
0.0, 1.0, | |
1.0, 1.0 | |
]) | |
this.screenSurface = { | |
position, | |
texture | |
} | |
} | |
createScreenTexture () { | |
this.createScreenBuffer() | |
this.screenTexture = this.gl.createTexture() | |
} | |
createScreenBuffer () { | |
const length = this.width * this.height | |
const screenBuffer = new Uint8Array(length) | |
screenBuffer.fill(0) | |
this.screenBuffer = screenBuffer | |
} | |
setScreenTexture (width, height, pixels) { | |
const { gl } = this | |
gl.activeTexture(gl.TEXTURE0 + 0) | |
this.setTexture(this.screenTexture, width, height, pixels) | |
} | |
// Palette | |
createPaletteTexture () { | |
this.createPaletteBuffer() | |
this.paletteTexture = this.gl.createTexture() | |
} | |
createPaletteBuffer () { | |
const paletteBuffer = new Uint8Array(256 * 4) | |
paletteBuffer.set(this.palette) | |
this.paletteBuffer = paletteBuffer | |
} | |
setPaletteTexture (width, height, pixels) { | |
const { gl } = this | |
gl.activeTexture(gl.TEXTURE0 + 2) | |
this.setTexture(this.paletteTexture, width, height, pixels, gl.RGBA) | |
} | |
// Shaders | |
buildShaderProgram () { | |
const { gl } = this | |
const program = gl.createProgram() | |
let shader | |
shader = this.compileShader(gl.VERTEX_SHADER, this.vertexShader) | |
if (shader) gl.attachShader(program, shader) | |
shader = this.compileShader(gl.FRAGMENT_SHADER, this.fragmentShader) | |
if (shader) gl.attachShader(program, shader) | |
gl.linkProgram(program) | |
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { | |
console.error('Error linking shader program:') | |
console.error(gl.getProgramInfoLog(program)) | |
} | |
gl.useProgram(program) | |
this.shaderProgram = program | |
} | |
compileShader (type, source) { | |
const { gl } = this | |
const shader = gl.createShader(type) | |
gl.shaderSource(shader, source) | |
gl.compileShader(shader) | |
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { | |
console.error(`Error compiling ${type === gl.VERTEX_SHADER ? 'vertex' : 'fragment'} shader:`) | |
console.error(gl.getShaderInfoLog(shader)) | |
} | |
return shader | |
} | |
// Array buffers | |
createArrayBuffer (array) { | |
const { gl } = this | |
const buffer = gl.createBuffer() | |
gl.bindBuffer(gl.ARRAY_BUFFER, buffer) | |
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(array), gl.STATIC_DRAW) | |
return buffer | |
} | |
setArrayBuffer (numComponents, locationName, buffer) { | |
const { gl } = this | |
const location = gl.getAttribLocation(this.shaderProgram, locationName) | |
gl.bindBuffer(gl.ARRAY_BUFFER, buffer) | |
gl.vertexAttribPointer(location, numComponents, gl.FLOAT, false, 0, 0) | |
gl.enableVertexAttribArray(location) | |
} | |
// Uniforms | |
getUniformLocation (key) { | |
return this.gl.getUniformLocation(this.shaderProgram, key) | |
} | |
setUniform (type, key, value) { | |
this.gl[`uniform${type}`](this.getUniformLocation(key), value) | |
} | |
} | |
const renderer = new PixelRenderer() | |
export async function enablePixelRenderer(glCanvas, options = { | |
paletteSrc: './palette.png', | |
scale: 3 | |
}) { | |
if (options.vertexShader) renderer.vertexShader = options.vertexShader | |
if (options.fragmentShader) renderer.fragmentShader = options.fragmentShader | |
renderer.handlers = [] | |
renderer.paletteCache = await paletteFromImage(options.paletteSrc) | |
renderer.glCanvas = glCanvas | |
renderer.gl = glCanvas.getContext('webgl') | |
renderer.gl.clearColor(1.0, 1.0, 1.0, 1.0) | |
renderer.buildShaderProgram() | |
renderer.createScreenSurface() | |
renderer.createPaletteTexture() | |
renderer.resize(glCanvas.width, glCanvas.height, options.scale) | |
renderer.run() | |
} | |
export function usePixelRenderer(handler, options) { | |
if (handler) { | |
renderer.handlers.push({ | |
fn: handler, | |
options | |
}) | |
renderer.handlers.sort(function (a, b) { | |
const aOrder = (a.options && typeof a.options.drawOrder === 'number') | |
? a.options.drawOrder | |
: 0 | |
const bOrder = (b.options && typeof b.options.drawOrder === 'number') | |
? b.options.drawOrder | |
: 0 | |
return aOrder - bOrder | |
}) | |
} | |
return [ | |
renderer.context, | |
renderer.canvas | |
] | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment