I\'m kinda stumped about this seemingly simple task of upscaling a canvas render in nearest neighbor format, which I asked here:
How can I properly write this shader
I can offer two approaches that can both efficiently scale an image up or down using nearest neighbor.
To do it manually, you should iterate through each pixel of your new scaled image and calculate what pixel from the original they should use using ratios of the old size compared to the new size.
(My code snippets use .toDataURL() so they may not work in chrome.)
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
#input {
display: none;
}
body {
background-color: black;
}
body > * {
display: block;
margin-top: 10px;
margin-left: auto;
margin-right: auto;
}
img {
background-color: gray;
border: solid 1px white;
border-radius: 10px;
image-rendering: optimizeSpeed;
}
label {
transition: 0.1s;
cursor: pointer;
text-align: center;
font-size: 15px;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
width: 130px;
height: 40px;
line-height: 40px;
border-radius: 10px;
color: white;
background-color: #005500;
box-shadow: 0px 4px #555555;
}
label:hover {
background-color: #007700;
}
label:active {
box-shadow: 0px 1px #555555;
transform: translateY(3px);
}
script {
display: none;
}
</style>
</head>
<body>
<img id="unscaledImage"></img>
<img id="scaledImage"></img>
<input id="scale" type="range" min="1" max="100" value="50"></input>
<label for="input">Upload Image</label>
<input id="input" type="file"></input>
<script type="application/javascript">
void function() {
"use strict";
var unscaledImage = null;
var scaledImage = null;
var scale = null;
var input = null;
var canvas = null;
var ctx = null;
var hasImage = false;
function scaleImage(img,scale) {
var newWidth = (img.width * scale) | 0;
var newHeight = (img.height * scale) | 0;
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img,0,0);
var unscaledData = ctx.getImageData(0,0,img.width,img.height);
var scaledData = ctx.createImageData(newWidth,newHeight);
var unscaledBitmap = unscaledData.data;
var scaledBitmap = scaledData.data;
var xScale = img.width / newWidth;
var yScale = img.height / newHeight;
for (var x = 0; x < newWidth; ++x) {
for (var y = 0; y < newHeight; ++y) {
var _x = (x * xScale) | 0;
var _y = (y * yScale) | 0;
var scaledIndex = (x + y * newWidth) * 4;
var unscaledIndex = (_x + _y * img.width) * 4;
scaledBitmap[scaledIndex] = unscaledBitmap[unscaledIndex];
scaledBitmap[scaledIndex + 1] = unscaledBitmap[unscaledIndex + 1];
scaledBitmap[scaledIndex + 2] = unscaledBitmap[unscaledIndex + 2];
scaledBitmap[scaledIndex + 3] = 255;
}
}
ctx.clearRect(0,0,canvas.width,canvas.height);
canvas.width = newWidth;
canvas.height = newHeight;
ctx.putImageData(scaledData,0,0);
return canvas.toDataURL();
}
function onImageLoad() {
URL.revokeObjectURL(this.src);
scaledImage.src = scaleImage(this,scale.value * 0.01);
scaledImage.style.width = this.width + "px";
scaledImage.style.height = this.height + "px";
hasImage = true;
}
function onImageError() {
URL.revokeObjectURL(this.src);
}
function onScaleChanged() {
if (hasImage) {
scaledImage.src = scaleImage(unscaledImage,this.value * 0.01);
}
}
function onImageSelected() {
if (this.files[0]) {
unscaledImage.src = URL.createObjectURL(this.files[0]);
}
}
onload = function() {
unscaledImage = document.getElementById("unscaledImage");
scaledImage = document.getElementById("scaledImage");
scale = document.getElementById("scale");
input = document.getElementById("input");
canvas = document.createElement("canvas");
ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;
unscaledImage.onload = onImageLoad;
unscaledImage.onerror = onImageError;
scale.onmouseup = onScaleChanged;
input.oninput = onImageSelected;
}
}();
</script>
</body>
</html>
Alternatively a much faster way using shaders, is to add your image to a texture that is set to use nearest neighbor filtering and draw it onto a quad. The size of the quad can be controlled via gl.viewport before drawing.
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
#file {
display: none;
}
body {
background-color: black;
}
body > * {
display: block;
margin-top: 10px;
margin-left: auto;
margin-right: auto;
}
img {
background-color: gray;
border: solid 1px white;
border-radius: 10px;
image-rendering: optimizeSpeed;
}
label {
transition: 0.1s;
cursor: pointer;
text-align: center;
font-size: 15px;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
width: 130px;
height: 40px;
line-height: 40px;
border-radius: 10px;
color: white;
background-color: #005500;
box-shadow: 0px 4px #555555;
}
label:hover {
background-color: #007700;
}
label:active {
box-shadow: 0px 1px #555555;
transform: translateY(3px);
}
script {
display: none;
}
</style>
</head>
<body>
<img id="unscaledImage"></img>
<img id="scaledImage"></img>
<input id="scale" type="range" min="1" max="100" value="50"></input>
<input id="file" type="file"></input>
<label for="file">Upload Image</label>
<script type="application/javascript">
void function() {
"use strict";
// DOM
var unscaledImage = document.getElementById("unscaledImage");
var scaledImage = document.getElementById("scaledImage");
var scale = document.getElementById("scale");
var file = document.getElementById("file");
var imageUploaded = false;
function onScaleChanged() {
if (imageUploaded) {
scaledImage.src = scaleOnGPU(this.value * 0.01);
}
}
function onImageLoad() {
URL.revokeObjectURL(this.src);
uploadImageToGPU(this);
scaledImage.src = scaleOnGPU(scale.value * 0.01);
scaledImage.style.width = this.width + "px";
scaledImage.style.height = this.height + "px";
imageUploaded = true;
}
function onImageError() {
URL.revokeObjectURL(this.src);
}
function onImageSubmitted() {
if (this.files[0]) {
unscaledImage.src = URL.createObjectURL(this.files[0]);
}
}
// GL
var canvas = document.createElement("canvas");
var gl = canvas.getContext("webgl",{ preserveDrawingBuffer: true })
var program = null;
var buffer = null;
var texture = null;
function uploadImageToGPU(img) {
gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,gl.RGBA,gl.UNSIGNED_BYTE,img);
}
function scaleOnGPU(scale) {
canvas.width = (unscaledImage.width * scale) | 0;
canvas.height = (unscaledImage.height * scale) | 0;
gl.viewport(0,0,canvas.width,canvas.height);
gl.drawArrays(gl.TRIANGLES,0,6);
return canvas.toDataURL();
}
// Entry point
onload = function() {
// DOM setup
unscaledImage.onload = onImageLoad;
unscaledImage.onerror = onImageError;
scale.onmouseup = onScaleChanged;
file.oninput = onImageSubmitted;
// GL setup
// Program (shaders)
var vertexShader = gl.createShader(gl.VERTEX_SHADER);
var fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
program = gl.createProgram();
gl.shaderSource(vertexShader,`
precision mediump float;
attribute vec2 aPosition;
attribute vec2 aUV;
varying vec2 vUV;
void main() {
vUV = aUV;
gl_Position = vec4(aPosition,0.0,1.0);
}
`);
gl.shaderSource(fragmentShader,`
precision mediump float;
varying vec2 vUV;
uniform sampler2D uTexture;
void main() {
gl_FragColor = texture2D(uTexture,vUV);
}
`);
gl.compileShader(vertexShader);
gl.compileShader(fragmentShader);
gl.attachShader(program,vertexShader);
gl.attachShader(program,fragmentShader);
gl.linkProgram(program);
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
gl.useProgram(program);
// Buffer
buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER,buffer);
gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([
1.0, 1.0, 1.0, 0.0,
-1.0, 1.0, 0.0, 0.0,
-1.0,-1.0, 0.0, 1.0,
1.0, 1.0, 1.0, 0.0,
-1.0,-1.0, 0.0, 1.0,
1.0,-1.0, 1.0, 1.0
]),gl.STATIC_DRAW);
gl.vertexAttribPointer(0,2,gl.FLOAT,gl.FALSE,16,0);
gl.vertexAttribPointer(1,2,gl.FLOAT,gl.FALSE,16,8);
gl.enableVertexAttribArray(0);
gl.enableVertexAttribArray(1);
// Texture
texture = gl.createTexture();
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D,texture);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_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);
}
onunload = function() {
gl.deleteProgram(program);
gl.deleteBuffer(buffer);
gl.deleteTexture(texture);
}
}();
</script>
</body>
</html>
Edit: To give better clarification on what this could look like in an actual renderer, I've created another example that draws a scene to a low resolution frame buffer and then scales it up to the canvas (The key is to set the min & mag filter to nearest neighbor).
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
background-color: black;
}
.center {
display: block;
margin-top: 30px;
margin-left: auto;
margin-right: auto;
border: solid 1px white;
border-radius: 10px;
}
script {
display: none;
}
</style>
</head>
<body>
<canvas id="canvas" class="center"></canvas>
<input id="scale" type="range" min="1" max="100" value="100" class="center"></input>
<script type="application/javascript">
void function() {
"use strict";
// DOM
var canvasWidth = 180 << 1;
var canvasHeight = 160 << 1;
var canvas = document.getElementById("canvas");
var scale = document.getElementById("scale");
function onScaleChange() {
var scale = this.value * 0.01;
internalWidth = (canvasWidth * scale) | 0;
internalHeight = (canvasHeight * scale) | 0;
gl.uniform1f(uAspectRatio,1.0 / (internalWidth / internalHeight));
gl.deleteFramebuffer(framebuffer);
gl.deleteTexture(framebufferTexture);
[framebuffer,framebufferTexture] = createFramebuffer(internalWidth,internalHeight);
}
// GL
var internalWidth = canvasWidth;
var internalHeight = canvasHeight;
var currentCubeAngle = -0.5;
var gl = canvas.getContext("webgl",{ preserveDrawingBuffer: true, antialias: false }) || console.warn("WebGL Not Supported.");
var cubeProgram = null; // Shaders to draw 3D cube
var scaleProgram = null; // Shaders to scale the frame
var uAspectRatio = null; // Aspect ratio for projection matrix
var uCubeRotation = null; // uniform location for cube program
var cubeBuffer = null; // cube model (attributes)
var scaleBuffer = null; // quad position & UV's
var framebuffer = null; // render target
var framebufferTexture = null; // textured that is rendered to. (The cube is drawn on this)
function createProgram(vertexCode,fragmentCode) {
var vertexShader = gl.createShader(gl.VERTEX_SHADER);
var fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(vertexShader,vertexCode);
gl.shaderSource(fragmentShader,fragmentCode);
gl.compileShader(vertexShader);
gl.compileShader(fragmentShader);
try {
if (!gl.getShaderParameter(vertexShader,gl.COMPILE_STATUS)) { throw "VS: " + gl.getShaderInfoLog(vertexShader); }
if (!gl.getShaderParameter(fragmentShader,gl.COMPILE_STATUS)) { throw "FS: " + gl.getShaderInfoLog(fragmentShader); }
} catch(error) {
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
console.error(error);
}
var program = gl.createProgram();
gl.attachShader(program,vertexShader);
gl.attachShader(program,fragmentShader);
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
gl.linkProgram(program);
return program;
}
function createBuffer(data) {
var buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER,buffer);
gl.bufferData(gl.ARRAY_BUFFER,Float32Array.from(data),gl.STATIC_DRAW);
return buffer;
}
function createFramebuffer(width,height) {
var texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D,texture);
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);
gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,width,height,0,gl.RGBA,gl.UNSIGNED_BYTE,null);
var _framebuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER,_framebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT0,gl.TEXTURE_2D,texture,0);
gl.bindTexture(gl.TEXTURE_2D,null);
gl.bindFramebuffer(gl.FRAMEBUFFER,null);
return [_framebuffer,texture];
}
function loop() {
//
currentCubeAngle += 0.01;
if (currentCubeAngle > 2.0 * Math.PI) {
currentCubeAngle = 0.0;
}
//
gl.bindFramebuffer(gl.FRAMEBUFFER,framebuffer);
gl.bindTexture(gl.TEXTURE_2D,null);
gl.viewport(0,0,internalWidth,internalHeight);
gl.useProgram(cubeProgram);
gl.uniform1f(uCubeRotation,currentCubeAngle);
gl.bindBuffer(gl.ARRAY_BUFFER,cubeBuffer);
gl.vertexAttribPointer(0,3,gl.FLOAT,gl.FALSE,36,0);
gl.vertexAttribPointer(1,3,gl.FLOAT,gl.FALSE,36,12);
gl.vertexAttribPointer(2,3,gl.FLOAT,gl.FALSE,36,24);
gl.enableVertexAttribArray(2);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES,0,24);
gl.bindFramebuffer(gl.FRAMEBUFFER,null);
gl.bindTexture(gl.TEXTURE_2D,framebufferTexture);
gl.viewport(0,0,canvasWidth,canvasHeight);
gl.useProgram(scaleProgram);
gl.bindBuffer(gl.ARRAY_BUFFER,scaleBuffer);
gl.vertexAttribPointer(0,2,gl.FLOAT,gl.FALSE,16,0);
gl.vertexAttribPointer(1,2,gl.FLOAT,gl.FALSE,16,8);
gl.disableVertexAttribArray(2);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES,0,6);
//
requestAnimationFrame(loop);
}
// Entry Point
onload = function() {
// DOM
canvas.width = canvasWidth;
canvas.height = canvasHeight;
scale.onmouseup = onScaleChange;
// GL
gl.clearColor(0.5,0.5,0.5,1.0);
gl.enable(gl.CULL_FACE);
gl.enableVertexAttribArray(0);
gl.enableVertexAttribArray(1);
cubeProgram = createProgram(`
precision mediump float;
const float LIGHT_ANGLE = 0.5;
const vec3 LIGHT_DIR = vec3(sin(LIGHT_ANGLE),0.0,cos(LIGHT_ANGLE));
const mat4 OFFSET = mat4(
1.0,0.0,0.0,0.0,
0.0,1.0,0.0,0.0,
0.0,0.0,1.0,0.0,
0.0,0.0,-5.0,1.0
);
const float FOV = 0.698132;
const float Z_NEAR = 1.0;
const float Z_FAR = 20.0;
const float COT_FOV = 1.0 / tan(FOV * 0.5);
const float Z_FACTOR_1 = -(Z_FAR / (Z_FAR - Z_NEAR));
const float Z_FACTOR_2 = -((Z_NEAR * Z_FAR) / (Z_FAR - Z_NEAR));
attribute vec3 aPosition;
attribute vec3 aNormal;
attribute vec3 aColour;
varying vec3 vColour;
uniform float uAspectRatio;
uniform float uRotation;
void main() {
float s = sin(uRotation);
float c = cos(uRotation);
mat4 PROJ = mat4(
COT_FOV * uAspectRatio,0.0,0.0,0.0,
0.0,COT_FOV,0.0,0.0,
0.0,0.0,Z_FACTOR_1,Z_FACTOR_2,
0.0,0.0,-1.0,0.0
);
mat4 rot = mat4(
c ,0.0,-s ,0.0,
0.0,1.0,0.0,0.0,
s ,0.0,c ,0.0,
0.0,0.0,0.0,1.0
);
vec3 normal = (vec4(aNormal,0.0) * rot).xyz;
vColour = aColour * max(0.4,dot(normal,LIGHT_DIR));
gl_Position = PROJ * OFFSET * rot * vec4(aPosition,1.0);
}
`,`
precision mediump float;
varying vec3 vColour;
void main() {
gl_FragColor = vec4(vColour,1.0);
}
`);
uAspectRatio = gl.getUniformLocation(cubeProgram,"uAspectRatio");
uCubeRotation = gl.getUniformLocation(cubeProgram,"uRotation");
gl.useProgram(cubeProgram);
gl.uniform1f(uAspectRatio,1.0 / (internalWidth / internalHeight));
scaleProgram = createProgram(`
precision mediump float;
attribute vec2 aPosition;
attribute vec2 aUV;
varying vec2 vUV;
void main() {
vUV = aUV;
gl_Position = vec4(aPosition,0.0,1.0);
}
`,`
precision mediump float;
varying vec2 vUV;
uniform sampler2D uTexture;
void main() {
gl_FragColor = texture2D(uTexture,vUV);
}
`);
cubeBuffer = createBuffer([
// Position Normal Colour
// Front
1.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0,0.0,0.6,
-1.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0,0.0,0.6,
-1.0,-1.0, 1.0, 0.0, 0.0, 1.0, 0.0,0.0,0.6,
-1.0,-1.0, 1.0, 0.0, 0.0, 1.0, 0.0,0.0,0.6,
1.0,-1.0, 1.0, 0.0, 0.0, 1.0, 0.0,0.0,0.6,
1.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0,0.0,0.6,
// Back
-1.0,-1.0,-1.0, 0.0, 0.0,-1.0, 0.0,0.0,0.6,
-1.0, 1.0,-1.0, 0.0, 0.0,-1.0, 0.0,0.0,0.6,
1.0, 1.0,-1.0, 0.0, 0.0,-1.0, 0.0,0.0,0.6,
1.0, 1.0,-1.0, 0.0, 0.0,-1.0, 0.0,0.0,0.6,
1.0,-1.0,-1.0, 0.0, 0.0,-1.0, 0.0,0.0,0.6,
-1.0,-1.0,-1.0, 0.0, 0.0,-1.0, 0.0,0.0,0.6,
// Left
-1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0,0.0,0.6,
-1.0,-1.0,-1.0, 1.0, 0.0, 0.0, 0.0,0.0,0.6,
-1.0,-1.0, 1.0, 1.0, 0.0, 0.0, 0.0,0.0,0.6,
-1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0,0.0,0.6,
-1.0, 1.0,-1.0, 1.0, 0.0, 0.0, 0.0,0.0,0.6,
-1.0,-1.0,-1.0, 1.0, 0.0, 0.0, 0.0,0.0,0.6,
// Right
1.0,-1.0, 1.0, -1.0, 0.0, 0.0, 0.0,0.0,0.6,
1.0,-1.0,-1.0, -1.0, 0.0, 0.0, 0.0,0.0,0.6,
1.0, 1.0, 1.0, -1.0, 0.0, 0.0, 0.0,0.0,0.6,
1.0,-1.0,-1.0, -1.0, 0.0, 0.0, 0.0,0.0,0.6,
1.0, 1.0,-1.0, -1.0, 0.0, 0.0, 0.0,0.0,0.6,
1.0, 1.0, 1.0, -1.0, 0.0, 0.0, 0.0,0.0,0.6
]);
scaleBuffer = createBuffer([
// Position UV
1.0, 1.0, 1.0,1.0,
-1.0, 1.0, 0.0,1.0,
-1.0,-1.0, 0.0,0.0,
1.0, 1.0, 1.0,1.0,
-1.0,-1.0, 0.0,0.0,
1.0,-1.0, 1.0,0.0
]);
[framebuffer,framebufferTexture] = createFramebuffer(internalWidth,internalHeight);
loop();
}
// Exit point
onunload = function() {
gl.deleteProgram(cubeProgram);
gl.deleteProgram(scaleProgram);
gl.deleteBuffer(cubeBuffer);
gl.deleteBuffer(scaleBuffer);
gl.deleteFramebuffer(framebuffer);
gl.deleteTexture(framebufferTexture);
}
}();
</script>
</body>
</html>