Skip to content

Instantly share code, notes, and snippets.

@vjancik
Created March 30, 2023 16:10
Show Gist options
  • Save vjancik/98faf183e332d1f793fd997e150eb017 to your computer and use it in GitHub Desktop.
Save vjancik/98faf183e332d1f793fd997e150eb017 to your computer and use it in GitHub Desktop.
Webgl Image Viewer
const canvas = document.getElementById("canvas");
const gl = canvas.getContext("webgl", { depth: false, stencil: false });
// TODO: get from GL
const MAX_CANVAS_DIM = 32767;
const MAX_TEXTURE_SIZE = gl.getParameter(gl.MAX_TEXTURE_SIZE);
const vertShaderSrc = `
attribute vec4 a_position;
attribute vec2 a_texCoord;
uniform mat4 u_mMVP;
varying vec2 v_texCoord;
void main() {
gl_Position = u_mMVP * a_position;
v_texCoord = a_texCoord;
}`;
const fragShaderSrc = `
#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
precision highp int;
precision highp sampler2D;
#else
precision mediump float;
precision mediump int;
precision mediump sampler2D;
#endif
uniform sampler2D u_image;
varying vec2 v_texCoord;
void main() {
gl_FragColor = texture2D(u_image, v_texCoord);
gl_FragColor.rgb *= gl_FragColor.a;
}
`;
gl.disable(gl.CULL_FACE);
gl.disable(gl.BLEND);
//gl.frontFace(gl.CCW);
gl.clearColor(0, 0, 0, 0);
const scale = window.devicePixelRatio || 1;
canvas.width = canvas.clientWidth * scale;
canvas.height = canvas.clientHeight * scale;
gl.viewport(0, 0, canvas.width, canvas.height);
const program = gl.createProgram();
const vertShader = gl.createShader(gl.VERTEX_SHADER);
const fragShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(vertShader, vertShaderSrc);
gl.compileShader(vertShader);
gl.shaderSource(fragShader, fragShaderSrc);
gl.compileShader(fragShader);
gl.attachShader(program, vertShader);
gl.attachShader(program, fragShader);
gl.linkProgram(program);
const linked = gl.getProgramParameter(program, gl.LINK_STATUS);
if (!linked) {
console.error(gl.getProgramInfoLog(program));
throw new Error("Failed to compile shader");
}
function triangleStripQuad(sX, sY, width, height) {
const res = new Float32Array(8);
res[0] = res[2] = sX;
res[1] = res[5] = sY;
res[4] = res[6] = sX + width;
res[3] = res[7] = sY + height;
return res;
}
const mMVPLoc = gl.getUniformLocation(program, "u_mMVP");
const positionLoc = gl.getAttribLocation(program, "a_position");
const positionBuffer = gl.createBuffer();
const texCoordLoc = gl.getAttribLocation(program, "a_texCoord");
const texCoordBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
gl.vertexAttribPointer(texCoordLoc, 2, gl.FLOAT, false, 0, 0);
gl.bufferData(gl.ARRAY_BUFFER, triangleStripQuad(0,0,1,1), gl.STATIC_DRAW);
gl.enableVertexAttribArray(texCoordLoc);
gl.useProgram(program);
// TODO: add function for testing whether a screen coordinate is in image
// TODO: mRotateate and mZoom must be applied at the same time to use the same coordinates, turn into mTransform, save all 3 separate transformations
// fixed ortho projection matrix looking down -z
const mOrtho = glMatrix.mat4.create();
glMatrix.mat4.ortho(mOrtho, -canvas.width / 2, canvas.width / 2, canvas.height / 2, -canvas.height / 2, 1, -1);
const mMV = glMatrix.mat4.create();
const mMVP = glMatrix.mat4.create();
const mTransform = glMatrix.mat4.create();
const fitImageScale = [1, 1, 1]; // view
const viewZoom = [1, 1, 1]; // view
let rotateRad = 0;
const viewAxis = [0, 0, -1];
// const center = glMatrix.vec3.fromValues(0.5, 0.5, 0); // model
// const invCenter = glMatrix.vec3.negate(glMatrix.vec3.create(), center);
const render = () => {
glMatrix.mat4.copy(mMV, mTransform); // view
glMatrix.mat4.scale(mMV, mMV, fitImageScale); // model -> view
glMatrix.mat4.mul(mMVP, mOrtho, mMV); // view -> proj
gl.uniformMatrix4fv(mMVPLoc, false, mMVP);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
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.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
const imageURL = "https://upload.wikimedia.org/wikipedia/commons/4/45/Eopsaltria_australis_-_Mogo_Campground.jpg";
const image = new Image();
image.crossOrigin = "anonymous";
image.onload = (evt) => {
const width = Math.min(MAX_TEXTURE_SIZE, evt.target.width);
const height = Math.min(MAX_TEXTURE_SIZE, evt.target.height);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0);
gl.bufferData(gl.ARRAY_BUFFER, triangleStripQuad(-width/2,-height/2,width,height),
gl.STATIC_DRAW);
gl.enableVertexAttribArray(positionLoc);
const imageAspectRatio = width / height;
const canvasAspectRatio = canvas.clientWidth / canvas.clientHeight;
if (imageAspectRatio > canvasAspectRatio)
fitImageScale[0] = fitImageScale[1] = canvas.width / width;
else
fitImageScale[0] = fitImageScale[1] = canvas.height / height;
if (Math.max(evt.target.width, evt.target.height) > MAX_TEXTURE_SIZE) {
const offscreen = new OffscreenCanvas(width, height);
const ctx = offscreen.getContext("2d");
ctx.drawImage(evt.target, 0, 0, width, height);
const imageData = ctx.getImageData(0, 0, width, height);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, imageData);
} else {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, evt.target);
}
window.requestAnimationFrame(render);
}
image.src = imageURL;
function arrMatToRows(arrMat, res = [new Array(4), new Array(4), new Array(4), new Array(4)]) {
const dim = Math.sqrt(arrMat.length);
for (let i = 0; i < dim; ++i) {
for (let j = 0; j < dim; ++j) {
res[i][j] = arrMat[j*dim + i];
}
}
return res;
}
function rowsToArrMat(rows, res = new Array(16)) {
const dim = rows.length;
for (let i = 0; i < dim; ++i) {
for (let j = 0; j < dim; ++j) {
res[i*4 + j] = typeof rows[j][i] === "number" ? rows[j][i] : rows[j][i].re;
}
}
return res;
}
const scrollScale = glMatrix.vec3.fromValues(1, 1, 1);
const scrollOrigin = glMatrix.vec3.fromValues(0, 0, 0);
const invScrollOrigin = glMatrix.vec3.fromValues(0, 0, 0);
const mZoom = glMatrix.mat4.create();
const mZoomInvert = glMatrix.mat4.create();
let zoomInCount = 0;
let invRootMatrix;
const invStepRotQuat = glMatrix.quat.create();
const invStepRotAxis = glMatrix.vec3.fromValues(0, 0, 0);
let invStepRotAngle = 0;
const arrBuff = glMatrix.mat4.create();
const rowBuff = [new Array(4), new Array(4), new Array(4), new Array(4)];
// TODO: scroll only when wheel event is above the image
canvas.addEventListener("wheel", evt => {
evt.preventDefault();
if (evt.deltaY < 0) {
scrollScale[0] = 1.2;
scrollScale[1] = 1.2;
glMatrix.vec3.mul(viewZoom, viewZoom, scrollScale);
scrollOrigin[0] = evt.offsetX - evt.target.clientWidth / 2;
scrollOrigin[1] = evt.offsetY - evt.target.clientHeight / 2;
glMatrix.vec3.negate(invScrollOrigin, scrollOrigin);
glMatrix.mat4.fromTranslation(mZoom, scrollOrigin);
glMatrix.mat4.scale(mZoom, mZoom, scrollScale);
glMatrix.mat4.translate(mZoom, mZoom, invScrollOrigin);
glMatrix.mat4.mul(mTransform, mZoom, mTransform);
++zoomInCount;
invRootMatrix = null;
} else if (evt.deltaY > 0) {
if (viewZoom[0] <= 1 + 2**(-15)) return;
if (zoomInCount && !invRootMatrix) {
glMatrix.mat4.invert(mZoomInvert, mTransform);
let eigs
try {
eigs = math.eigs(arrMatToRows(mZoomInvert, rowBuff));
} catch (e) {
console.log("Eig decomp cocked up");
console.log(mTransform, arrMatToRows(mZoomInvert, rowBuff), viewZoom, rotateRad);
throw e;
}
const ERoot = math.dotPow(eigs.values, 1 / zoomInCount);
invRootMatrix = rowsToArrMat(math.multiply(eigs.vectors, math.diag(ERoot), math.inv(eigs.vectors)), arrBuff);
glMatrix.mat4.getScaling(scrollScale, invRootMatrix);
glMatrix.mat4.getRotation(invStepRotQuat, invRootMatrix);
invStepRotAngle = glMatrix.quat.getAxisAngle(invStepRotAxis, invStepRotQuat);
}
glMatrix.mat4.mul(mTransform, invRootMatrix, mTransform);
glMatrix.vec3.mul(viewZoom, viewZoom, scrollScale);
rotateRad += (invStepRotAxis[2] > 0 ? -1 : 1) * invStepRotAngle;
--zoomInCount;
if (viewZoom[0] < 1.1)
console.log(viewZoom[0], rotateRad);
}
// console.log(glMatrix.mat4.str(mZoom));
window.requestAnimationFrame(render);
});
const panOp = document.getElementById("panOp");
const rotateOp = document.getElementById("rotateOp")
let operation = "pan";
const changeOperation = evt => {
operation = evt.target.value;
if (evt.target.id !== panOp.id) panOp.checked = false;
if (evt.target.id !== rotateOp.id) rotateOp.checked = false;
}
panOp.addEventListener("change", changeOperation);
rotateOp.addEventListener("change", changeOperation);
let rotateCenter = glMatrix.vec3.fromValues(0, 0, 0);
let invRotateCenter = glMatrix.vec3.fromValues(0, 0, 0);
let mousePressed = false;
let mouseMoved = false;
let crossVec = glMatrix.vec3.create();
let mRotate = glMatrix.mat4.create();
const startPos = new Array(2);
const onMouseDown = (evt) => {
mousePressed = true;
startPos[0] = evt.offsetX;
startPos[1] = evt.offsetY;
if (operation == "pan") {
rotateCenter[0] = 0;
rotateCenter[1] = 0;
}
}
const onMouseUp = (evt) => {
if (operation == "rotate" && !mouseMoved) {
rotateCenter[0] = startPos[0] - evt.target.clientWidth / 2;
rotateCenter[1] = startPos[1] - evt.target.clientHeight / 2;
glMatrix.vec2.negate(invRotateCenter, rotateCenter);
}
mousePressed = false;
mouseMoved = false;
}
const onMouseMove = (evt) => {
if (!mousePressed) return;
mouseMoved = true;
if (operation == "pan") {
const transVec = [evt.offsetX - startPos[0], evt.offsetY - startPos[1], 0];
glMatrix.vec3.div(transVec, transVec, viewZoom);
glMatrix.mat4.rotate(mTransform, mTransform, -rotateRad, viewAxis);
glMatrix.mat4.translate(mTransform, mTransform, transVec);
glMatrix.mat4.rotate(mTransform, mTransform, rotateRad, viewAxis);
rotateCenter[0] += evt.offsetX - startPos[0];
rotateCenter[1] += evt.offsetY - startPos[1];
glMatrix.vec2.negate(invRotateCenter, rotateCenter);
} else if (operation == "rotate") {
const centerX = evt.target.clientWidth / 2 + rotateCenter[0];
const centerY = evt.target.clientHeight / 2 + rotateCenter[1];
const vec1 = [startPos[0] - centerX, startPos[1] - centerY];
const vec2 = [evt.offsetX - centerX, evt.offsetY - centerY];
const angle = glMatrix.vec2.angle(vec1, vec2);
glMatrix.vec2.cross(crossVec, vec1, vec2);
const stepRotateRad = (crossVec[2] > 0 ? -1 : 1) * angle;
glMatrix.mat4.fromTranslation(mRotate, rotateCenter);
glMatrix.mat4.rotate(mRotate, mRotate, stepRotateRad, viewAxis);
glMatrix.mat4.translate(mRotate, mRotate, invRotateCenter);
glMatrix.mat4.mul(mTransform, mRotate, mTransform);
rotateRad += stepRotateRad;
}
startPos[0] = evt.offsetX;
startPos[1] = evt.offsetY;
window.requestAnimationFrame(render);
};
const initPanning = () => {
canvas.addEventListener("mousemove", onMouseMove);
canvas.addEventListener("mousedown", onMouseDown);
canvas.addEventListener("mouseup", onMouseUp);
canvas.addEventListener("mouseleave", onMouseUp);
};
initPanning();
const error = gl.getError();
if (error) console.log("Error Code: " + error);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment