Posts Tagged ‘computer graphics’

Where we live

Using a number of technologies I’ve been playing around with recently, I began working on a 3D visualization of the Earth, plotting every city, creating a pointillism-styled representation of the planet. Below is the result along with an overview of how I produced the rendering.

Getting the data

I extracted all cities with a population of at least 100,000 people from the MySQL GeoNames database using the following query:

SELECT `id`,`name`,`latitude`,`longitude`,`population`,`timezone`
FROM geonames.cities
WHERE population >= 100000 AND feature_class = 'P';

… and put the results into a JS array.

Creating a 3D model to represent each city

I created this hexagonal model in Blender, exported it to a Wavefront OBJ file, and ran the OBJ file through the Wavefront OBJ to JSON converter I wrote. Note that the model is facing the z-axis to match WebGL’s (and OpenGL’s) default camera orientation: facing down the negative z-axis.

Convert longitude and latitude to a 3D position

Converting a geodetic longitude, latitude pair to a 3D position involves doing a LLA (Longitude Latitude Altitude) to ECEF (Earth-Centered, Earth-Fixed) transformation. The code below implements this transform, converting the longitude and latitude of every city pulled from the GeoNames database into a 3D coordinate where we can render the hexagonal representation of the city.

function llarToWorld(lat, lon, alt, rad)
{            
    lat = lat * (Math.PI/180.0);
    lon = lon * (Math.PI/180.0);

    
var f = 0; //flattening
    
var ls = Math.atan( Math.pow((1.0 - f),2) * Math.tan(lat) ); // lambda

    
var x = rad * Math.cos(ls) * Math.cos(lon) + alt * Math.cos(lat) * Math.cos(lon)
    
var y = rad * Math.cos(ls) * Math.sin(lon) + alt * Math.cos(lat) * Math.sin(lon)
    
var z = rad * Math.sin(ls) + alt * Math.sin(lat)
    
    
return [x,z,-y];            
}

There are 2 items worth noting:

  • The transformation (and function above) involve a 4th parameter, radius which is the radius of the ellipsoid (or sphere, in this case, as flattening=0) into which the transformation is done. I have it set as a fixed constant, as I’m primary concerned with an approximate visual representation, but the MathWorks page describes the actual computation.
  • The ECEF (Earth-Centered, Earth-Fixed) coordinate system has the z-axis pointing north, not the y-axis, so the z and y values need to be swapped to produce a coordinate corresponding to WebGL’s default camera orientation. In addition, as WebGL has a right-handed coordinate system (so the default camera orientation is one where it’s pointing down the negative z-axis), the z coordinate is negated so the point doesn’t wind up behind the camera.

Orient all cities to face the origin

Getting each of the hexagonal models to face the origin involved a bit of math:

  • Calculating the axis about which the rotation should occur by, first, computing a vector from the origin to the 3D position of the model (lookAt), and taking the cross product between lookAt and the z-axis (as we’re rotating toward the z-axis).
  • Calculating the angle of rotation (the angle between the z-axis and lookAt) by computing the dot product between lookAt and the z-axis, then taking the acos of the dot product.

There’s some additional code to handle cases where points lie on the on the z-axis (where the cross product gives the zero vector) and also to return a matrix representation of the rotation.

function lookAtOrigin(v)
{
// compute vector from origin
var lookAt = vec3.create([v[0], v[1], -v[2]]);
vec3.normalize(lookAt);

// reference axis
var refAxis = vec3.create([0,0,-1]);

// computate axis of rotation
var rotAxis = vec3.create(lookAt);
vec3.cross(rotAxis, refAxis);

// compute angle of rotation
var rotAngRad = Math.acos(vec3.dot(lookAt, refAxis));

// special cases...
if(rotAxis[0] == 0 && rotAxis[1] == 0 && rotAxis[2] == 0) {
if(lookAt[2] > 0) {
rotAxis = vec3.create([1,0,0]);
rotAngRad = Math.PI;
}
else {
rotAxis = vec3.create([1,0,0]);
rotAngRad = 0;
}
}

// compute and return a matrix with the rotation
var ret = mat4.identity();
mat4.rotate(ret, rotAngRad, rotAxis);

return ret;
}

Render the scene

Using glfx, I pulled everything together, also adding a bit of code to rotate the camera and do some pseudo-lighting in the pixel shader by alpha blending colors based on depth. All the code can be found in the webgl-globe repository on bitbucket.

Entering the world of high-DPI displays

With a Retina iPad and my recent purchase of a Yoga 2 laptop with a “Quad HD” display (3200×1800) I’ve been dragged into the world of high-DPI (more precisely PPI) displays. For years, DPI was “standard” at either 96dpi (Windows) or 72dpi (Mac OS), with a logical/software pixel being equivalent to a hardware pixel on the display device. A higher resolution monitor meant the content on your display got a bit smaller but you gained a couple more thousand pixels to work with, but the recent and massive increases in pixel densities seems to be the end of the 1:1 mapping between software and hardware pixel references. Below are a few notes on my experiences dealing with high-DPI displays and content so far.

  • Windows 8.1 support is terrible. Both application support and operating system support for high-DPI displays is abysmal. See the post Living a High-DPI desktop lifestyle can be painful by Scott Hanselman which reflects many of the issues I’ve encountered with my Yoga 2 as well. There’s a large list of issues: for non-DPI aware applications Windows scale text but not icons and layout, applications lie about being DPI-aware and are rendered too small, and some applications simply crash (TourtiseSVN’s diff… no clue why, too many pixels?!). It’s easy to wag a finger at application developers, but legacy support is clearly something the operating system needs to handle. In addition, despite Windows 8.1 touting automatic, per-monitor, DPI detection, it’s all based off of the DPI of a “primary monitor” and content on the other monitors is scaled to match. So dragging a window for an application from the Yoga 2 display across to a HD/96dpi monitor results in the window being scaled down (and visibly blurry). Worse, all applications undergo the same treatment – so if you’re thinking you can just use non-DPI aware applications on an external display until support comes around, guess again.
    ArsTechnica did a piece mentioning this issue in particular, and Windows 8.1’s high-DPI support is general.
  • Web support is only half-way there. The best thing done to support high-DPI displays was defining the CSS2 reference pixel to be independent of hardware pixels. Beyond that you have media queries and higher resolution background images, but there’s still no good way to specify alternate foreground images, though the <picture> element may gain support soon. In general, outside of CSS things gets messy, as is the case with a high-DPI <canvas>.
    One problem with the web that I don’t see a proposed solution for is handling low-resolution image assets for which you can’t get a higher resolution version. This is a problem I face with this blog. There’s a lot of images (old screenshots, low-resolution photos, etc.) for which I can’t get a 2x, 4x, etc., higher resolution version and there’s no way to prevent upscaling the images or specify how the upscaling is done. The typical 2x-bilinear-filtered upscaling, done by most browsers, is not always desirable. In addition, as display vendors pack more pixels in, what happens when the “high-resolution” version needed is 4x or 8x?
  • SVG/Vector-based images aren’t always the answer. There’s a lot of benefits to vector-based formats, but they’re not the holy grail many think they are. For vector-based images, rendering costs grow as you add details with polygons and paths. It’s why video games still rely heavily on texture mapping, even as graphics hardware has progressed to handle rendering millions of polygons per frame – the additional geometry and computation for fine details is enormous.

glfx – WebGL basis

The base code for my WebGL experiments have been pretty sloppy thus far. I recently took some time to cleanup the code in order to have a more solid basis to work from and I’m presenting it here as a primer for anyone looking for a simple bootstrap or a code-heavy intro to WebGL.

A walk-through of the base code (glfx) and sample code to generate the demo shown below follows. The code is also available via the glfx bitbucket repository.

Dependencies

For matrix and vector operations, the glMatrix library.

Also window.requestAnimationFrame needs to be defined. For older browsers the following shim can be used:

window.requestAnimationFrame = (function(time){
return window.requestAnimationFrame ||
         window.webkitRequestAnimationFrame ||
         window.mozRequestAnimationFrame ||
         window.oRequestAnimationFrame ||
         window.msRequestAnimationFrame ||
        
function( callback ){
            window.setTimeout(callback, 1000 / 60);
         };
})();    

glfx

glfx is the crux of the rendering interface and encapsulates the WebGL context, functionality to load assets (shaders, textures, models), and functionality to setup and render the scene.

// glfx object wraps everything necessary for the rendering interface
var glfx = { };

// echo function to output debug statements to console
glfx.echo = function(txt) {
if(typeof console.log !== 'undefined') {
console.log(txt);
}
}

// WebGL context
glfx.gl = null;

// reference count for assets needed before rendering()
glfx.assetRef = 0;
// function to call when all assets are loaded, set by user via glfx.whenAssetsLoaded, reset internally
glfx.onAssetsLoaded = function() { };
// function to schedule callback when all assets are loaded, set by user
glfx.whenAssetsLoaded = function(_callback) {
if(typeof _callback !== 'undefined') {
if(glfx.assetRef === 0) {
_callback();
}
else {
glfx.onAssetsLoaded = _callback;
}
}
}
// function to increment asset ref count
glfx.incAssetRef = function() {
glfx.assetRef++;
if(glfx.assetRef === 0) {
glfx.onAssetsLoaded();
glfx.onAssetsLoaded =
function() { }; // reset
}
}
// function to decrement asset ref count
glfx.decAssetRef = function() {
glfx.assetRef--;
}

// Shaders class
glfx.shaders = { };
// buffer to store loaded shaders
glfx.shaders.buffer = new Array();

// Function to load vertex shader from external file
// _url = path to shader source
// _type = gl.VERTEX_SHADER / gl.FRAGMENT_SHADER
// _callback = function to call after shader is created, shader object passed is shader is successfully compiled, null otherwise
glfx.shaders.load = function(_url, _name, _type, _callback) {
glfx.decAssetRef();

var xmlhttp = new XMLHttpRequest();
xmlhttp.onreadystatechange =
function() {                
if (xmlhttp.readyState == 4 && xmlhttp.status == 200) {

var shaderSrc = xmlhttp.responseText;
var shader = glfx.gl.createShader(_type);

glfx.gl.shaderSource(shader, shaderSrc);
glfx.gl.compileShader(shader);

if (!glfx.gl.getShaderParameter(shader, glfx.gl.COMPILE_STATUS)) {
shader =
null;
}

if(typeof _callback !== 'undefined') {
_callback(shader);
}

glfx.shaders.buffer[_name] = shader;

glfx.incAssetRef();
}
}

xmlhttp.open(
"GET", _url, true);
xmlhttp.send();
}


// Textures class
glfx.textures = { };
// Textures array
glfx.textures.buffer = new Array();
// Method to load texture from file
glfx.textures.load = function(_path, _name) {

glfx.decAssetRef();

glfx.textures.buffer[_name] = glfx.gl.createTexture();

var tex=glfx.textures.buffer[_name];
tex.image =
new Image();
tex.image.onload =
function() {                

var tex = glfx.textures.buffer[_name];                                                            
glfx.gl.bindTexture(glfx.gl.TEXTURE_2D, tex);
glfx.gl.pixelStorei(glfx.gl.UNPACK_FLIP_Y_WEBGL,
true);
glfx.gl.texImage2D(glfx.gl.TEXTURE_2D, 0, glfx.gl.RGBA, glfx.gl.RGBA, glfx.gl.UNSIGNED_BYTE, tex.image);

glfx.gl.texParameteri(glfx.gl.TEXTURE_2D, glfx.gl.TEXTURE_MAG_FILTER, glfx.gl.LINEAR);
glfx.gl.texParameteri(glfx.gl.TEXTURE_2D, glfx.gl.TEXTURE_MIN_FILTER, glfx.gl.LINEAR);

// required for non-power-of-2 textures
glfx.gl.texParameteri(glfx.gl.TEXTURE_2D, glfx.gl.TEXTURE_WRAP_S, glfx.gl.CLAMP_TO_EDGE);
glfx.gl.texParameteri(glfx.gl.TEXTURE_2D, glfx.gl.TEXTURE_WRAP_T, glfx.gl.CLAMP_TO_EDGE);

glfx.gl.bindTexture(glfx.gl.TEXTURE_2D,
null);

glfx.incAssetRef();

}

tex.image.src = _path;            
}


// Model class
glfx.model = function() {

this.vertexBuffer = null;
this.indexBuffer = null;
this.texcoordBuffer = null;
this.normalBuffer = null;

}

// Models class
glfx.models = { };
// Models array
glfx.models.buffer = new Array();
// Method to load models from JSON file
glfx.models.load = function(_url, _name, _callback) {

glfx.decAssetRef();

var xmlhttp = new XMLHttpRequest();
xmlhttp.onreadystatechange =
function() {                
if (xmlhttp.readyState == 4 && xmlhttp.status == 200) {

var data = JSON.parse(xmlhttp.responseText);

var mdl = new glfx.model();

mdl.vertexBuffer = glfx.gl.createBuffer();
glfx.gl.bindBuffer(glfx.gl.ARRAY_BUFFER, mdl.vertexBuffer);
glfx.gl.bufferData(glfx.gl.ARRAY_BUFFER,
new Float32Array(data.verts), glfx.gl.STATIC_DRAW);
mdl.vertexBuffer.itemSize = 3;
mdl.vertexBuffer.numItems = data.verts.length / 3;

mdl.indexBuffer = glfx.gl.createBuffer();
glfx.gl.bindBuffer(glfx.gl.ELEMENT_ARRAY_BUFFER, mdl.indexBuffer);
glfx.gl.bufferData(glfx.gl.ELEMENT_ARRAY_BUFFER,
new Uint16Array(data.indices), glfx.gl.STATIC_DRAW);
mdl.indexBuffer.itemSize = 1;
mdl.indexBuffer.numItems = data.indices.length;        

if(data.texcoords.length > 0) {
mdl.texcoordBuffer = glfx.gl.createBuffer();
glfx.gl.bindBuffer(glfx.gl.ARRAY_BUFFER, mdl.texcoordBuffer);
glfx.gl.bufferData(glfx.gl.ARRAY_BUFFER,
new Float32Array(data.texcoords), glfx.gl.STATIC_DRAW);
mdl.texcoordBuffer.itemSize = 2;
mdl.texcoordBuffer.numItems = data.texcoords.length / 2;            
}

if(data.normals.length > 0) {
mdl.normalBuffer = glfx.gl.createBuffer();
glfx.gl.bindBuffer(glfx.gl.ARRAY_BUFFER, mdl.normalBuffer);
glfx.gl.bufferData(glfx.gl.ARRAY_BUFFER,
new Float32Array(data.normals), glfx.gl.STATIC_DRAW);
mdl.normalBuffer.itemSize = 3;
mdl.normalBuffer.numItems = data.normals / 3;
}

glfx.models.buffer[_name] = mdl;

glfx.incAssetRef();
}
}

xmlhttp.open(
"GET", _url, true);
xmlhttp.send();
}


// Scene class
glfx.scene = { };
// Scene last render time
glfx.scene.ptime = 0;
// Model-View matrix
glfx.scene.matModelView = null;
// Perspective matrix
glfx.scene.matPerspective = null;
// Scene graph
glfx.scene.graph = new Array();

// Class for scene (world) objects
// _base = object with vertex buffer, index buffer, texture coordinate buffer, etc.
glfx.scene.worldObject = function(_base, _shaderProgram) {
this.base = _base;            
this.shprog = _shaderProgram;
this.position = vec3.create();
this.rotation = vec3.create();
this.scale = vec3.create([1.0, 1.0, 1.0]);
this.update = function() { };
}

// method to add object to scene graph
glfx.scene.addWorldObject = function(_wo) {
glfx.scene.graph.push(_wo);
}

// set field of view
glfx.setFOV = function(_fov) {
mat4.perspective(_fov, glfx.gl.viewportWidth / glfx.gl.viewportHeight, 0.1, 100.0, glfx.scene.matPerspective);
}

// set clear color
glfx.setClearColor = function(_color) {
glfx.gl.clearColor(_color[0], _color[1], _color[2], _color[3]);
}

// Initialization function
// _canvas = DOM canvas element
// _onInitComplete (optional) = callback after init is complete
glfx.init = function(_canvas, _onInitComplete) {

glfx.gl = _canvas.getContext(
"experimental-webgl", {antialias:true});
if (!glfx.gl) {
glfx.echo(
"No webGL support.");
return false;
}

// Set viewport width,height based on dimensions of canvas element
glfx.gl.viewportWidth = _canvas.width;
glfx.gl.viewportHeight = _canvas.height;            

// Set clear color
glfx.setClearColor([1,1,1,1]);

// Enable depth buffer
glfx.gl.enable(glfx.gl.DEPTH_TEST);                

// Setup scene matrices
glfx.scene.matPerspective = mat4.create();
glfx.scene.matModelView = mat4.create();            
glfx.setFOV(90);

// Reset render target
glfx.gl.bindTexture(glfx.gl.TEXTURE_2D, null);
glfx.gl.bindRenderbuffer(glfx.gl.RENDERBUFFER,
null);
glfx.gl.bindFramebuffer(glfx.gl.FRAMEBUFFER,
null);

// Execute callback if one was passed
if(typeof _onInitComplete !== 'undefined') {
_onInitComplete();
}

// Begin rendering
glfx.render(0);

return true;
}

// Render loop function
glfx.render = function(time) {

requestAnimationFrame(glfx.render);

if(glfx.assetRef < 0) {
return;
}

// Reset framebuffer
glfx.gl.bindFramebuffer(glfx.gl.FRAMEBUFFER, null);        

// Clear viewport
glfx.gl.viewport(0, 0, glfx.gl.viewportWidth, glfx.gl.viewportHeight);
glfx.gl.clear(glfx.gl.COLOR_BUFFER_BIT | glfx.gl.DEPTH_BUFFER_BIT);                    

// Calculate frame time delta
var tdelta = 0;
if(glfx.scene.ptime > 0) {
tdelta = time - glfx.scene.ptime;
}    
glfx.scene.ptime = time;

// Render all models in scene
for(var i=0; i<glfx.scene.graph.length; i++) {

mat4.identity(glfx.scene.matModelView);                

glfx.scene.graph[i].update(tdelta, glfx.scene.graph[i]);
var objpos = glfx.scene.graph[i].position;
var objrot = glfx.scene.graph[i].rotation;
var objscale = glfx.scene.graph[i].scale;

mat4.scale(glfx.scene.matModelView, objscale);
mat4.translate(glfx.scene.matModelView, objpos);
mat4.rotate(glfx.scene.matModelView, objrot[0], [1, 0, 0]);                
mat4.rotate(glfx.scene.matModelView, objrot[1], [0, 1, 0]);        
mat4.rotate(glfx.scene.matModelView, objrot[2], [0, 0, 1]);                        

glfx.scene.graph[i].render(tdelta, glfx.scene.graph[i], glfx.scene.matModelView, glfx.scene.matPerspective);
}

}

Initializing glfx

Initializing glfx simply involves calling the glfx.init() function with the canvas element that’s going to be used to render on.

var canvasElem = document.getElementById('wgl-canvas');
glfx.init(canvasElem);

This will setup the rendering interface which will begin rendering frames, but as there is nothing in the scene only a clear is done when a frame is rendered. The clear color is set to white (1,1,1,1) and the field of view set to 90deg by default; these can be changed with the glfx.setClearColor() and glfx.setFOV() methods, respectively.

Loading assets

Assets (shaders, textures, and models) are loaded asynchronously via AJAX requests. As there may be dependencies on multiple assets for rendering and scene creation, a simple semaphore is used, glfx.assetRef.

  • glfx.assetRef is decremented when a new request for an asset is issued and incremented once the AJAX call succeeds and the asset has been created.
  • When glfx.assetRef < 0, it indicates a pending asset for the scene and no rendering is done.
  • A callback can be scheduled for when glfx.assetRef = 0 (i.e. all pending assets loaded) via the glfx.whenAssetsLoaded() method.
// Load basic shaders for rendering
glfx.shaders.load('basic.vs', "vert-shader-basic", glfx.gl.VERTEX_SHADER);
glfx.shaders.load(
'basictex.fs', "frag-shader-tex", glfx.gl.FRAGMENT_SHADER);

// Load necessary textures
glfx.textures.load('img/test.png', 'test-tex');                    

// Load models used in scene
glfx.models.load('cube.json', 'cubemdl', glfx.models.jsonParser);

Note that all the asset load methods take a URL as the first argument, and a name as the second argument. The name is an identifier by which to lookup the asset from the buffer it’s stored in. Also, glfx.models.jsonParser is the only model parser available and loads models corresponding to the JSON data produced by my Wavefront OBJ to JSON converter.

Building a scene

After assets are loaded, we can can create shader programs and world objects, then add them to the scene.

glfx.whenAssetsLoaded(function() {

// Create shader program from loaded shaders
var shprog = glfx.gl.createProgram();
glfx.gl.attachShader(shprog, glfx.shaders.buffer[
'vert-shader-basic']);
glfx.gl.attachShader(shprog, glfx.shaders.buffer[
'frag-shader-tex']);
glfx.gl.linkProgram(shprog);

if (!glfx.gl.getProgramParameter(shprog, glfx.gl.LINK_STATUS)) {
alert(
"Could not create shader program");
return false;
}

// Setup variables for shader program
shprog.vertexPositionAttribute = glfx.gl.getAttribLocation(shprog, "aVertexPosition");
glfx.gl.enableVertexAttribArray(shprog.vertexPositionAttribute);            

shprog.pMatrixUniform = glfx.gl.getUniformLocation(shprog,
"uPMatrix");
shprog.mvMatrixUniform = glfx.gl.getUniformLocation(shprog,
"uMVMatrix");

shprog.textureCoordAttribute = glfx.gl.getAttribLocation(shprog,
"aTextureCoord");
glfx.gl.enableVertexAttribArray(shprog.textureCoordAttribute);                        


// add some cubes to the scene graph
var cubeA = new glfx.scene.worldObject(glfx.models.buffer['cubemdl'], shprog);
cubeA.position = vec3.create([-1.6, 0.0, -25.0]);
cubeA.rotation = vec3.create([0.0, 0.0, 0.0]);
cubeA.scale = vec3.create([0.70, 1.0, 1.0]);
cubeA.render =
function(tdelta, wobj, matModelView, matPerspective) {
// Setup shader program to use
var shprog = wobj.shprog;
glfx.gl.useProgram(shprog);    

var tex = glfx.textures.buffer['test-tex'];                
glfx.gl.activeTexture(glfx.gl.TEXTURE0);
glfx.gl.bindTexture(glfx.gl.TEXTURE_2D, tex);
glfx.gl.uniform1i(shprog.samplerUniform, 0);


glfx.gl.bindBuffer(glfx.gl.ARRAY_BUFFER, wobj.base.vertexBuffer);
glfx.gl.vertexAttribPointer(shprog.vertexPositionAttribute, wobj.base.vertexBuffer.itemSize, glfx.gl.FLOAT,
false, 0, 0);                

glfx.gl.bindBuffer(glfx.gl.ARRAY_BUFFER, wobj.base.texcoordBuffer);
glfx.gl.vertexAttribPointer(shprog.textureCoordAttribute, wobj.base.texcoordBuffer.itemSize, glfx.gl.FLOAT,
false, 0, 0);                    

glfx.gl.uniformMatrix4fv(shprog.pMatrixUniform,
false, matPerspective);
glfx.gl.uniformMatrix4fv(shprog.mvMatrixUniform,
false, matModelView);                

glfx.gl.bindBuffer(glfx.gl.ELEMENT_ARRAY_BUFFER, wobj.base.indexBuffer);
glfx.gl.drawElements(glfx.gl.TRIANGLES, wobj.base.indexBuffer.numItems, glfx.gl.UNSIGNED_SHORT, 0);    
}

cubeA.update =
function(tdelta, wobj) {

// some code to position and spin cubeA

if(wobj.position[2] < -5.0) {
wobj.position[2] += 0.022 * tdelta;
}
else {
wobj.position[2] = -5.0;
}

wobj.rotation[0] = 0.35;
wobj.rotation[1] += -(75 * tdelta) / 50000.0;
if( Math.abs(wobj.rotation[1]) >= 2.0*Math.PI ) {
wobj.rotation[1] = 0.0;
}
}
glfx.scene.addWorldObject( cubeA );


// Add another cube to the scene
var cubeB = new glfx.scene.worldObject(glfx.models.buffer['cubemdl'], shprog);
cubeB.position = vec3.create([1.6, 0.0, -25.0]);
cubeB.rotation = vec3.create([0.0, 0.0, 0.0]);
cubeB.scale = vec3.create([0.70, 1.0, 1.0]);
cubeB.update =
function(tdelta, wobj) {
// some code to position and spin cubeB
if(cubeA.position[2] > -15.0) {
if(wobj.position[2] < -5.0) {
wobj.position[2] += 0.022 * tdelta;
}
else {
wobj.position[2] = -5.0;
}
}

wobj.rotation[0] = 0.35;
wobj.rotation[1] += -(75 * tdelta) / 50000.0;
if( Math.abs(wobj.rotation[1]) >= 2.0*Math.PI ) {
wobj.rotation[1] = 0.0;
}
}

cubeB.render =
function(tdelta, wobj, matModelView, matPerspective) {
// Setup shader program to use
var shprog = wobj.shprog;
glfx.gl.useProgram(shprog);    

var tex = glfx.textures.buffer['test-tex'];                
glfx.gl.activeTexture(glfx.gl.TEXTURE0);
glfx.gl.bindTexture(glfx.gl.TEXTURE_2D, tex);
glfx.gl.uniform1i(shprog.samplerUniform, 0);


glfx.gl.bindBuffer(glfx.gl.ARRAY_BUFFER, wobj.base.vertexBuffer);
glfx.gl.vertexAttribPointer(shprog.vertexPositionAttribute, wobj.base.vertexBuffer.itemSize, glfx.gl.FLOAT,
false, 0, 0);                

glfx.gl.bindBuffer(glfx.gl.ARRAY_BUFFER, wobj.base.texcoordBuffer);
glfx.gl.vertexAttribPointer(shprog.textureCoordAttribute, wobj.base.texcoordBuffer.itemSize, glfx.gl.FLOAT,
false, 0, 0);                    

glfx.gl.uniformMatrix4fv(shprog.pMatrixUniform,
false, matPerspective);
glfx.gl.uniformMatrix4fv(shprog.mvMatrixUniform,
false, matModelView);                

glfx.gl.bindBuffer(glfx.gl.ELEMENT_ARRAY_BUFFER, wobj.base.indexBuffer);
glfx.gl.drawElements(glfx.gl.TRIANGLES, wobj.base.indexBuffer.numItems, glfx.gl.UNSIGNED_SHORT, 0);    
}

glfx.scene.addWorldObject( cubeB );

});

Shaders for programs are pulled from the glfx.shaders.buffer[] associative array, referenced by the name specified when they were loaded.

Once we have a shader program and a model, we can create items for the scene by constructing glfx.scene.worldObject objects:

  • Construct the glfx.scene.worldObject object by specifying a model from the glfx.models.buffer[] associative array and the shader program as arguments to the constructor.
  • The worldObject.position, worldObject.rotation, and worldObject.scale vectors can be set as desired.
  • The worldObject.update() method can be overridden to describe how to manipulate the object in each frame.
  • The worldObject.render() method can be overridden to render the objects making use of the underlying buffers in worldObject.base: worldObject.base.indexBuffer, worldObject.base.vertexBuffer, worldObject.base.normalBuffer, worldObject.base.texcoordBuffer, as well as textures from the glfx.textures.buffer[] associative array.
  • Note that transformation on the model-view matrix (matModelView) is done within glfx.render() and should not be done within worldObject.render().

This callback is not ideal. I’m exposing a lot of rendering code that would best be abstracted away to glfx. However, without a strict definition of how a model should be textured or what variables are to be passed over to the vertex and fragment shaders, abstracting further is premature.

Wavefront OBJ to JSON converter

I began experimenting with WebGL a while back and hit a wall when it came to importing geometry data into a scene, as you can only get so far with programmatically generated cubes and spheres. I looked into an Wavefront OBJ to JSON conversion tool (to just spit out the vertices, normals, and texture coordinates of the OBJ model), but couldn’t find much. There’s a Blender plugin for Three.js, but I didn’t want a dependency on Three.js nor the Three.js JSON file format; I have nothing against either, but I didn’t want to add a thick layer of abstraction like Three.js and, honestly, I wanted to delve into the OBJ format and deal with working on geometry data at the vertex level.

The result is a simple OBJ to JSON converter written in C++. The code for the entire program is below and also available via the bitbucket repository.

One of my test models was the Clocktower model by thinice and shown below is a WebGL rendering of the converted geometry data.

The program takes 2 arguments:

  • The path of the input OBJ file
  • The name of the output JSON file

OBJ to JSON converter

Note that the converter does have some limitations:

  • It will not deal with materials (i.e. it does not parse any corresponding MTL files)
  • It will only parse triangle faces (no quadrilaterals). If the model has quadilaterals, you can convert them to triangles in Blender by going into Edit mode, selecting all vertices, and hitting CTRL + T.
  • Named objects and polygon groups are ignored; the converter essentially treats everything in the file as a single polygon group.

#include <iostream>
#include <vector>
#include <cstdio>


struct vec2
{
    
public:
        vec2(
float _u, float _v) : u(_u), v(_v) { }
        
        
float u;
        
float v;
};

struct idx3
{
    
public:
        idx3(
int _a, int _b, int _c) : a(_a), b(_b), c(_c) { }

        
bool operator==(const idx3& other) const {
            
if( this->a == other.a && this->b == other.b && this->c == other.c) {
                
return true;
            }

            
return false;
        }

        
int a;
        
int b;
        
int c;
};

struct vec4
{
    
public:
        vec4(
float _x, float _y, float _z, float _w) : x(_x), y(_y), z(_z), w(_w) { }
        
        
float x;
        
float y;
        
float z;
        
float w;
};

struct tri
{
    
public:
        tri(
int _v1, int _v2, int _v3) : v1(_v1), v2(_v2), v3(_v3), vn1(0), vn2(0), vn3(0), vt1(0), vt2(0), vt3(0) { }
        
        
int v1;
        
int vn1;
        
int vt1;

        
int v2;
        
int vn2;
        
int vt2;
        
        
int v3;
        
int vn3;
        
int vt3;
};

struct polygroup
{
    
public:
        std::vector<vec4>    verts;
        std::vector<vec4>    normals;
        std::vector<vec2>    texcoords;
        std::vector<tri>    tris;
};

struct polygroup_denormalized
{
    
public:
        std::vector<vec4>    verts;
        std::vector<vec4>    normals;
        std::vector<vec2>    texcoords;
        std::vector<
int>    indexbuf;
};

void echo(const char* line)
{
    std::cout << line << std::endl;
}

vec4 parseVertex(
const char* line)
{
    
char prefix[4];
    
float x, y, z;

    sscanf(line,
"%s %f %f %f", prefix, &x, &y, &z);

    
return vec4(x,y,z,1);
}

vec2 parseTexCoord(
const char* line)
{
    
char prefix[4];
    
float u, v;

    sscanf(line,
"%s %f %f", prefix, &u, &v);

    
return vec2(u,v);
}


std::vector<
int> readFace(const char* fstr)
{
    std::vector<
int> ret;

    
char buf[64];
    
int bufidx = 0;
    
for(int i=0; i<strlen(fstr); i++) {

        
if(fstr[i] != '/') {
            buf[bufidx++] = fstr[i];
        }
else {
            ret.push_back( atoi(buf) );
            bufidx = 0;
            memset(buf, 0, 64);
// clear buffer
        
}
    }

    
if(strlen(buf) > 0) {
        ret.push_back( atoi(buf) );
    }

    
return ret;
}

tri parseTriFace(
const char* line)
{
    
char prefix[4];
    
char p1[64];
    
char p2[64];
    
char p3[64];

    
int v1=0, v2=0, v3=0;
    
int vn1=0, vn2=0, vn3=0;
    
int vt1=0, vt2=0, vt3=0;

    sscanf(line,
"%s %s %s %s", prefix, p1, p2, p3);

    std::vector<
int> f1 = readFace(p1);
    
if(f1.size() >= 1) { v1 = f1[0] - 1; }
    
if(f1.size() >= 2) { vt1 = f1[1] - 1; }
    
if(f1.size() >= 3) { vn1 = f1[2] - 1; }

    std::vector<
int> f2 = readFace(p2);
    
if(f2.size() >= 1) { v2 = f2[0] - 1; }
    
if(f2.size() >= 2) { vt2 = f2[1] - 1; }
    
if(f2.size() >= 3) { vn2 = f2[2] - 1; }

    std::vector<
int> f3 = readFace(p3);
    
if(f3.size() >= 1) { v3 = f3[0] - 1; }
    
if(f3.size() >= 2) { vt3 = f3[1] - 1; }
    
if(f3.size() >= 3) { vn3 = f3[2] - 1; }


    tri ret(v1, v2, v3);
    ret.vt1 = vt1;
    ret.vt2 = vt2;
    ret.vt3 = vt3;
    ret.vn1 = vn1;
    ret.vn2 = vn2;
    ret.vn3 = vn3;

    
return ret;
}

std::vector<polygroup*> polygroups_from_obj(
const char* filename)
{
    
bool inPolyGroup = false;
    polygroup* curPolyGroup = NULL;
    std::vector<polygroup*>    polygroups;

    FILE* fp = fopen(filename,
"r");
    
if(fp == NULL) {
        echo(
"ERROR: Input file not found");
        
return polygroups;
    }

    
// make poly group
    
if(curPolyGroup == NULL) {
        curPolyGroup =
new polygroup();
        polygroups.push_back(curPolyGroup);
    }

    
// parse
    
echo("reading OBJ geometry data...");
    
while(true) {

        
char buf[2056];
        
if(fgets(buf, 2056, fp) != NULL) {

            
if(strlen(buf) >= 1) {

                
// texture coordinate line
                
if(strlen(buf) >= 2 && buf[0] == 'v' && buf[1] == 't') {
                    vec2 tc = parseTexCoord(buf);
                    curPolyGroup->texcoords.push_back(tc);
                }
                
// vertex normal line
                
else if(strlen(buf) >= 2 && buf[0] == 'v' && buf[1] == 'n') {
                    vec4 vn = parseVertex(buf);
                    curPolyGroup->normals.push_back(vn);
                }
                
// vertex line
                
else if(buf[0] == 'v') {
                    vec4 vtx = parseVertex(buf);
                    curPolyGroup->verts.push_back(vtx);
                }
                
// face line (ONLY TRIANGLES SUPPORTED)
                
else if(buf[0] == 'f') {
                    tri face = parseTriFace(buf);
                    curPolyGroup->tris.push_back(face);
                }
                
else
                
{ }

            }

        }
else {
            
break;
        }
    }

    fclose(fp);

    
return polygroups;
}


std::string int_array_to_json_array(
const std::vector<int>& arr)
{
    std::string json =
"[";
    
for(int i=0; i<arr.size(); i++) {

        
char buf[256];
        sprintf(buf,
"%i", arr[i]);
        
        
if(i > 0) {
            json.append(
",");
        }

        json.append(buf);
    }

    json.append(
"]");

    
return json;
}

std::string vec4_array_to_json_array(
const std::vector<vec4>& arr)
{
    std::string json =
"[";
    
for(int i=0; i<arr.size(); i++) {

        
char buf[64];
        sprintf(buf,
"%f", arr[i].x);
        
        
if(i > 0) {
            json.append(
",");
        }

        json.append(buf);

        sprintf(buf,
"%f", arr[i].y);
        json.append(
",");
        json.append(buf);

        sprintf(buf,
"%f", arr[i].z);
        json.append(
",");
        json.append(buf);
    }

    json.append(
"]");

    
return json;
}

std::string vec2_array_to_json_array(
const std::vector<vec2>& arr)
{
    std::string json =
"[";
    
for(int i=0; i<arr.size(); i++) {

        
char buf[64];
        sprintf(buf,
"%f", arr[i].u);
        
        
if(i > 0) {
            json.append(
",");
        }

        json.append(buf);

        sprintf(buf,
"%f", arr[i].v);
        json.append(
",");
        json.append(buf);
    }

    json.append(
"]");

    
return json;
}


polygroup_denormalized* denormalize_polygroup(polygroup& pg)
{
    polygroup_denormalized* ret =
new polygroup_denormalized();

    std::vector<idx3> processedVerts;

    
for(int i=0; i<pg.tris.size(); i++) {

        
for(int v=0; v<3; v++) {
            
            idx3 vidx(0,0,0);
            
if(v == 0) {
                vidx = idx3(pg.tris[i].v1, pg.tris[i].vn1, pg.tris[i].vt1);
            }
else if(v == 1) {
                vidx = idx3(pg.tris[i].v2, pg.tris[i].vn2, pg.tris[i].vt2);
            }
else if (v == 2) {
                vidx = idx3(pg.tris[i].v3, pg.tris[i].vn3, pg.tris[i].vt3);
            }
else { }


            
// check if we already processed the vert
            
int indexBufferIndex = -1;
            
for(int pv=0; pv<processedVerts.size(); pv++) {
                
if(vidx == processedVerts[pv]) {
                    indexBufferIndex = pv;
                    
break;
                }
            }

            
// add to buffers
            
if(indexBufferIndex == -1) {

                processedVerts.push_back(vidx);

                ret->verts.push_back(pg.verts[vidx.a]);

                
if(pg.normals.size() > 0) {
                    ret->normals.push_back(pg.normals[vidx.b]);
                }

                
if(pg.texcoords.size() > 0) {
                    ret->texcoords.push_back(pg.texcoords[vidx.c]);
                }

                
int idx = (int)ret->verts.size() - 1;
                ret->indexbuf.push_back(idx);

            }
else {
                ret->indexbuf.push_back(indexBufferIndex);
            }

        }

    }

    
return ret;
}

void polygroup_to_json(polygroup& pg, const char* jsonFilename)
{
    echo(
"denormalizing polygroup...");
    polygroup_denormalized* dpg = denormalize_polygroup(pg);

    echo(
"making verts array...");
    std::string vertsStr =
"";
    vertsStr.append(
"\"verts\":");
    vertsStr.append(vec4_array_to_json_array(dpg->verts));
    vertsStr.append(
",");

    echo(
"making indices array...");
    std::string indicesStr =
"";
    indicesStr.append(
"\"indices\":");
    indicesStr.append(int_array_to_json_array(dpg->indexbuf));
    indicesStr.append(
",");

    echo(
"making texcoords array...");
    std::string texcoordsStr =
"";
    texcoordsStr.append(
"\"texcoords\":");
    
if(dpg->texcoords.size() > 0) {
        texcoordsStr.append(vec2_array_to_json_array(dpg->texcoords));
    }
else {
        texcoordsStr.append(
"[]");
    }
    texcoordsStr.append(
",");

    echo(
"making normals array...");
    std::string normalsStr =
"";
    normalsStr.append(
"\"normals\":");
    
if(dpg->normals.size() > 0) {
        normalsStr.append(vec4_array_to_json_array(dpg->normals));
    }
else {
        normalsStr.append(
"[]");
    }


    echo(
"writing output file...");
    FILE *fp = fopen(jsonFilename,
"w");
    fputs(
"{", fp);
    fputs(vertsStr.c_str(), fp);
    fputs(
"\n", fp);    
    fputs(indicesStr.c_str(), fp);
    fputs(
"\n", fp);
    fputs(texcoordsStr.c_str(), fp);
    fputs(
"\n", fp);
    fputs(normalsStr.c_str(), fp);
    fputs(
"}", fp);
    fclose(fp);

    
delete dpg;
    dpg = NULL;
}


int main(int argc, char *argv[])
{

    echo(
"OBJ to JSON converter");

    
if(argc < 3) {
        echo(
"ERROR: Invalid arguments");
        echo(
"ARGS: wavefrontOBJtoJSON.exe <inputFile> <outputFile>");
        
return 0;
    }

    
char* inputFilename = argv[1];
    
char* outputFilename = argv[2];

    echo(
"reading OBJ data into polygroup...");
    std::vector<polygroup*> pg = polygroups_from_obj(inputFilename);

    
if(pg.size() > 0) {
        echo(
"converting polygroup to JSON arrays...");
        polygroup_to_json(*pg[0], outputFilename);
    }

    
// cleanup
    
for(int i=0; i<pg.size(); i++) {
        
delete pg[i];
        pg[i] = NULL;
    }
    pg.clear();

    echo(
"done.");

    
return 0;

}

One notable aspect of the conversion is denormalizing the geometry [done in denormalize_polygroup()]. An OBJ file stores unique lists of vertices, normals, texture coordinates, etc. and for each face there is an index into the list of vertices, an index into the list of normals, etc. This is great when it comes to storing data (as it eliminates duplicate geometry data, and decreases the file size) but when rendering you can’t have the data organized like this, as you can only have a single index buffer, where each index corresponds to the same location within the list of vertices, normal, texture coordinates, etc. Therefore, data must be duplicated such that every combination of vertex coordinate, texture coordinate, normal, etc. is uniquely identified by an entry in the index buffer (e.g. if 2 vertices have the same position but different texture coordinates, it has to be identified by a different index in the index buffer, and the position must be duplicated so that the new texture coordinate and position can be referenced by the different index).

EDIT (11/1/2013): In the initial code committed and presented was outputting Javascript variables set equal to arrays, the code has been updated to output valid JSON data instead. Furthermore, the namespace argument for the program is no longer required and no type of namespacing is done on the output data.

Circular stipple patterns on an HTML5 canvas

In my previous post on stipple patterns, I presented code to draw a few simple stipple patterns based on drawing single pixels at fixed locations. In this post, I’ll present something just a bit more complex: drawing circles to create a circular stipple patten, again writing a shader that makes use of the GraphicsCore and FXController classes.

Shader.circleStippleShader = function (imageData, bufWrite, index, x, y, r, g, b, a, passNum, frameNum, maxFrames)
{
var alpha = 1.0;
var r1 = r / 255.0;
var rF = Math.floor((alpha * r1 + (1.0 - alpha)) * 255.0);

var circleMaxDiam = 12; // circle at every 12th pixel, also defines max diameter of circle,
var circleMaxRadius = circleMaxDiam / 2; // maximum radius of circle

// figure out the x, y indices of the circle we're within
// x,y need to be shifted by the circle radius b/c circleMaxDiam defined the offset of the circle center
// ... e.g. going along the x-axis, we are within the next circle not at x/circleMaxDiam, but at (x+6)/circleMaxDiam
var iX = Math.floor((x + circleMaxRadius) / circleMaxDiam);
var iY = Math.floor((y + circleMaxRadius) / circleMaxDiam);

// multiply the circle indices by the diameter to get the actual coordinates of the circle's center
var targetX = iX * circleMaxDiam;
var targetY = iY * circleMaxDiam;

// calculate squared distance to the circle we are within
var dist = (targetX - x) * (targetX - x) + (targetY - y) * (targetY - y);

if (dist < 25) {
GraphicsCore.setPixel(bufWrite, index, rF, 0, 0, 255);
}
else {
GraphicsCore.setPixel(bufWrite, index, r, g, b, 255);
}

}
Shader.circleStippleShader.numPassesRequired = 1;    

Circle stipple

Conceptually, we define the center of a circle at every 12th pixel (both along the x and y axis). At every pixel (x,y) we figure out which circle we are within, and calculate the distance to the center. If the distance is less than our threshold (25), we change the color of the pixel (use the red channel only).

Stipple patterns on an HTML5 canvas

I wanted to play around a bit with stipple patterns after seeing stippling done with photos on the LinkedIn news feed. However, what I’m going to present is not what LinkedIn does. LinkedIn applies the stipple pattern as a background-image on a DOM element above a <img> element with a (fairly low resolution) JPEG – the stippling may help to alleviate the negative visual impact of the low-resolution image. What I’m going to show is how to do stippling on an HTML5 canvas, which allows for a much greater degree of freedom in terms of what’s possible, but is also slower and requires a modern browser.

I’m going to make use of the GraphicsCore and FXController classes in a previous post, Gaussian blur on an HTML5 canvas. In that post I presented the concept of writing shaders as plug-in to the FXController class to apply different per-pixel effects. What I’m going to present are shaders for a few simple stipple patterns. Applying the shader is simply a matter of passing it into the constructor for the FXController class, e.g.

var theShader = Shader.crossStippleShader;
var fxCtrlr = new FXController(ctxSource, ctxDest, theShader, width, height, 100, 1);
fxCtrlr.init();

Checkerboard Stipple

This shader has the effect of creating a checkerboard pattern.
The source pixel is preserved if (x+y) % 2 == 0, otherwise the pixel’s alpha is reduced to 66.

Shader.checkerboardStippleShader = function (imageData, bufWrite, index, x, y, r, g, b, a, passNum, frameNum, maxFrames)
{                
    
if( (x+y)%2 == 0) {
        GraphicsCore.setPixel(bufWrite, index, r, g, b, 255);
    }
    
else {
        GraphicsCore.setPixel(bufWrite, index, r, g, b, 66);
    }
}
Shader.checkerboardStippleShader.numPassesRequired = 1;    

Checkerboard Stipple

Dot Stipple

This shader blends a white pixel into the source image where x%2 == 0 && y%2 == 0, in effect creating a dotted grid pattern.

The alpha blending code is a straightforward implementation of alpha compositing, but since we’re blending with white (where r=1.0, g=1.0, and b=1.0) the equation is simplified and there is no second color value; we’re just biasing the source color by the alpha value. Also note that this is different than simply changing the alpha of the source pixel (as was done in the Checkerboard Stipple shader), here we’re always blending with white, in the previous shader we’re blending with whatever is the background of the DOM element.

Shader.dotStippleShader = function (imageData, bufWrite, index, x, y, r, g, b, a, passNum, frameNum, maxFrames)
{    
    
var alpha = 0.8;
        
    
var r1 = r / 255.0;
    
var rF = Math.floor((alpha*r1 + (1.0-alpha)) * 255.0);
            
    
var g1 = g / 255.0;
    
var gF = Math.floor((alpha*g1 + (1.0-alpha)) * 255.0);

    
var b1 = b / 255.0;
    
var bF = Math.floor((alpha*b1 + (1.0-alpha)) * 255.0);            
            
    
if( x%2 == 0 && y%2 == 0) {
        GraphicsCore.setPixel(bufWrite, index, rF, gF, bF, 255);
    }
else {
                
        GraphicsCore.setPixel(bufWrite, index, r, g, b, 255);
    }            

}
Shader.dotStippleShader.numPassesRequired = 1;

Dot Stipple

Quincunx Stipple

With this shader we blend in a white pixel at every 4 pixels (x%4 == 0 && y%4 == 0, the target pixel) and also at the 4 orthogonally adjacent pixels around the target, creating a quincunx pattern.

Shader.quincunxStippleShader = function (imageData, bufWrite, index, x, y, r, g, b, a, passNum, frameNum, maxFrames)
{    
    
var alpha = 0.78;
        
    
var r1 = r / 255.0;
    
var rF = Math.floor((alpha*r1 + (1.0-alpha)) * 255.0);
            
    
var g1 = g / 255.0;
    
var gF = Math.floor((alpha*g1 + (1.0-alpha)) * 255.0);

    
var b1 = b / 255.0;
    
var bF = Math.floor((alpha*b1 + (1.0-alpha)) * 255.0);            
            
    
if( (x%4 == 0 && y%4 == 0) ||
        ((x+1)%4 == 0 && y%4 == 0) ||
        ((x-1)%4 == 0 && y%4 == 0) ||
        (x%4 == 0 && (y+1)%4 == 0) ||
        (x%4 == 0 && (y-1)%4 == 0) )
    {
        GraphicsCore.setPixel(bufWrite, index, rF, gF, bF, 255);
    }
    
else
    
{
        GraphicsCore.setPixel(bufWrite, index, r, g, b, 255);
    }            

}
Shader.quincunxStippleShader.numPassesRequired = 1;

Quincunx Stipple

Cross Stipple

Similar to the quincunx stipple, but we blend in a white pixel at every 6 pixels (x%6 == 0 && y%6 == 0, the target pixel), the 4 orthogonally adjacent pixels around the target, and 4 additional pixels extending beyond the orthogonals, creating a cross (“+”) pattern.

Shader.crossStippleShader = function (imageData, bufWrite, index, x, y, r, g, b, a, passNum, frameNum, maxFrames)
{    
    
var alpha = 0.78;
        
    
var r1 = r / 255.0;
    
var rF = Math.floor((alpha*r1 + (1.0-alpha)) * 255.0);
            
    
var g1 = g / 255.0;
    
var gF = Math.floor((alpha*g1 + (1.0-alpha)) * 255.0);

    
var b1 = b / 255.0;
    
var bF = Math.floor((alpha*b1 + (1.0-alpha)) * 255.0);            
            
    
if( (x%6 == 0 && y%6 == 0) ||
        ((x+1)%6 == 0 && y%6 == 0) ||
        ((x-1)%6 == 0 && y%6 == 0) ||
        ((x+2)%6 == 0 && y%6 == 0) ||
        ((x-2)%6 == 0 && y%6 == 0) ||                
        (x%6 == 0 && (y+1)%6 == 0) ||
        (x%6 == 0 && (y-1)%6 == 0) ||
        (x%6 == 0 && (y+2)%6 == 0) ||
        (x%6 == 0 && (y-2)%6 == 0)                 
        )
                
    {
        GraphicsCore.setPixel(bufWrite, index, rF, gF, bF, 255);
    }
    
else
    
{
        GraphicsCore.setPixel(bufWrite, index, r, g, b, 255);
    }            

}
Shader.crossStippleShader.numPassesRequired = 1;    

Cross Stipple

That’s all for now. There’s tons of variations possible with only minor code changes to alter blending, color, and the shape of the stipple patten.

Batching, a basis for optimization

It’s interesting that in 3 distinct domains I’ve run across the same underlying basis for optimization:

  • Graphics: Modern GPUs depend heavily on batching primitives, typically triangles. Instead of rendering triangles individually, you get much better performance by batching primitives together in a list, sending it to the GPU via a single call, then letting the GPU pipelines to do their thing. Even before modern GPUs existed, graphics cards supported techniques like BitBlt which, essentially, performed operations on batched blocks of pixels, to take advantage of the embarrassingly parallel nature of computer graphics.
  • Relational Databases: Issuing lots of small queries can kill performance. A better strategy is, usually, to issue fewer queries, joining and returning as much data as possible with each query. Even if these queries becomes complex and costly, the cost of a complex query will usually still be less than the aggregate cost of numerous simpler queries.
  • Networking: The speed of light sucks… server and packet switching latencies make things worse. I usually assume ~50ms baseline latency to send a request packet + get a reply packet back from an internet server (I use the term “packet” loosely, referring to programmer-defined, application-level “packets” or messages, or whatever you like to call them, not necessarily TCP/IP packets). Note that this baseline is regardless of the amount of information in a packet and is bound by the travel time between server and client. So, to optimize communication and bandwidth, a good strategy is to transfer as much as possible per-packet instead of depending upon numerous requests/responses to/from a server, which would mean lots of packets and lots of wasted time.

TransparencyExtract

A long while ago, I wrote a post on a method to extract transparency from images with solid-colored backgrounds, and somewhat effectively reduce the halo effect around objects in the image. I never got around to releasing the code or application, which I’m finally doing now.

All code + app is here.
It’s in C# + WinForms, and I put the entire VS solution in the bitbucket repo.

TransparencyExtract app

The guts of it all is the PerformExtraction() function in TransparencyExtract.cs

public void PerformExtraction()
{
ColorF bgColorF = new ColorF(backgroundColor);
ColorF fgColorF = new ColorF(foregroundColor);
float backL = bgColorF.L();
float foreL = fgColorF.L();

float minL = Math.Min(backL, foreL);
float deltaL = Math.Max(backL, foreL) - minL;


newBitmap =
new Bitmap(srcBmp.Width, srcBmp.Height, PixelFormat.Format32bppArgb);

BitmapData bdDest = newBitmap.LockBits(new Rectangle(0, 0, newBitmap.Width, newBitmap.Height), System.Drawing.Imaging.ImageLockMode.ReadWrite, newBitmap.PixelFormat);
IntPtr ptrDest = bdDest.Scan0;

BitmapData bd = srcBmp.LockBits(new Rectangle(0, 0, srcBmp.Width, srcBmp.Height), System.Drawing.Imaging.ImageLockMode.ReadWrite, srcBmp.PixelFormat);
IntPtr ptr = bd.Scan0;

int bpp = 4;
int bytes = srcBmp.Height * bd.Stride;
byte[] rgbValues = new byte[bytes];
byte[] rgbValuesDest = new byte[bytes];
System.Runtime.InteropServices.
Marshal.Copy(ptr, rgbValues, 0, bytes);
System.Runtime.InteropServices.
Marshal.Copy(ptrDest, rgbValuesDest, 0, bytes);

Array.Copy(rgbValues, rgbValuesDest, rgbValues.Length);

for (int y = 0; y < srcBmp.Height; y++)
{
for (int x = 0; x < srcBmp.Width; x++)
{
int idx = (x * bpp + y * bd.Stride);

ColorF cf = new ColorF(Color.FromArgb(rgbValues[idx + 3], rgbValues[idx + 2], rgbValues[idx + 1], rgbValues[idx]));

// scale from [minL, 1] -> [0,1]
float scaleUpCoeff = 1.0f / deltaL;
float ld = (cf.L() - minL) * scaleUpCoeff;

if (ld > 1.0f)
ld = 1.0f;

if (ld < 0.0f)
ld = 0.0f;

float alpha = 1.0f - ld;

rgbValuesDest[idx+3] = (
byte)(alpha * 255.0f); // this is the alpha
}
}

System.Runtime.InteropServices.
Marshal.Copy(rgbValues, 0, ptr, bytes);
System.Runtime.InteropServices.
Marshal.Copy(rgbValuesDest, 0, ptrDest, bytes);

srcBmp.UnlockBits(bd);
newBitmap.UnlockBits(bdDest);
}

Note, this is not a generic algorithm by any means; read my original post to understand what’s going on and the limitations of this method.

Gaussian blur on an HTML5 canvas

I’m actually going to present more than the actual gaussian blur implementation, also showing how to setup a simple animation controller and lightweight pixel shader system, allowing for defining the final color of a pixel on a per-pixel basis and allowing other effects to easily be plugged into the system. Be warned, this stuff is slow. This is all CPU processing (atop JavaScript no-less), there’s, sadly, no GPU hardware acceleration here. If your thinking about doing this on high-resolution images or writing effects that require a ton of passes over the image, you’re going bring the browser to a crawl, even on a fairly high-end system.

gaussian blur on HTML5 canvas element

I can’t embed JavaScript within this post, so you’ll have to go here to view the result (obviously, you’ll need an HTML 5 capable browser). For those who are curious, the very cool test image used is of a bird of paradise flower by the Agricultural Research Service.

So, first things first, the HTML, which is very simple. There’s 2 canvas element, one will hold the source image and the other will be the destination for the post-processed image. The width and height attributes on the canvas elements are set to the width and height of the image.

<!DOCTYPE html>
<html>
<head>
<title>HTML5 Blur FX</title>

<
script type="text/javascript">
// JS code will go here!
</
script>

</
head>
<body>
<canvas id="cvs-source" width="72" height="50">
</canvas>

<canvas id="cvs-dest" width="72" height="50">
</canvas>
</body>
</html>

Next, load the test image onto the source canvas (cvs-source) and setup the destination canvas (cvs-dest) as a blank image, which will occur when the page is loaded. Ignore the reference to the FXController object for now.

window.onload = function ()
{
var img = new Image();
img.onload =
function ()
{
// setup source
var ctxSource = document.getElementById('cvs-source').getContext('2d');
ctxSource.drawImage(img, 0, 0);

// setup destination
var cvsElement = document.getElementById('cvs-dest');
var ctxDest = cvsElement.getContext('2d');

var width = parseInt(cvsElement.getAttribute("width"));
var height = parseInt(cvsElement.getAttribute("height"));

ctxDest.createImageData(width, height);

var theShader = Shader.gaussBlur;
var fxCtrlr = new FXController(ctxSource, ctxDest, theShader, width, height, 10, 10);
fxCtrlr.init();
}

img.src =
'test3.png';
}

Stepping away from the actual code for a minute, it’s important to note how to actually modify the pixels on a canvas element:

  • Get the 2d context of the element by calling getContext(‘2d’) on the DOM element.
  • Call CanvasRenderingContext2D.getImageData(…) to get a buffer with the pixels in RGBA format.
  • To commit changes to the pixels onto a canvas, call CanvasRenderingContext2D.putImageData(…) with the buffer of modified pixels.
var ctxSource = document.getElementById('cvs-source').getContext('2d');
var imageData = ctxSource.getImageData(0, 0, width, height);


ctxDest.putImageData(bufWrite, 0, 0);

Back to the actual code. One of the very simple and primitive operations needed is to set a pixel to a color:

// GraphicsCore object
var GraphicsCore = {};
GraphicsCore.setPixel =
function (imageData, index, r, g, b, a)
{
imageData.data[index + 0] = r;
imageData.data[index + 1] = g;
imageData.data[index + 2] = b;
imageData.data[index + 3] = a;
}

I didn’t implement a corresponding getPixel() function because, as you’ll see, it’s very clean and easy to get the a pixel directly from the buffer and wasn’t worth invoking a function call.

The FXController object is (for the most part) the animation controller.

// FXController object
function FXController(_ctxSource, _ctxDest, _theShader, _width, _height, _fps, _maxFrames)
{
this.ctxSource = _ctxSource;
this.ctxDest = _ctxDest;
this.theShader = _theShader;
this.width = _width;
this.height = _height;
this.fps = _fps;
this.curFrame = 1; // [1, ...]
this.maxFrames = _maxFrames;
this.numPassesPerFrame = _theShader.numPassesRequired;
this.invervalPtr = null;

this.shaderFunc = function (fxCtrlr, passNum, frameNum, maxFrames)
{
Shader.run(fxCtrlr.ctxSource, fxCtrlr.ctxDest, fxCtrlr.width, fxCtrlr.height, fxCtrlr.theShader, passNum, frameNum, maxFrames);
}

this.init = function ()
{
var fxCtrlr = this;
var runFunc = function () { fxCtrlr.run(fxCtrlr); }

this.invervalPtr = setInterval(runFunc, 1000.0 / this.fps);
}

this.unInit = function()
{
clearInterval(
this.invervalPtr);
this.invervalPtr = null;
}

this.run = function (sender /*FXController*/)
{
for (var pn = 1; pn <= sender.numPassesPerFrame; pn++) {
sender.shaderFunc(sender, pn, sender.curFrame, sender.maxFrames);
}

sender.curFrame++;
if (sender.curFrame > sender.maxFrames) {
sender.unInit();
}
}
}

Most of what going on here is simply holding values which are passed to Shader.run(…). However, a few important things are being setup:

  • FXController.run(…) will be called at a certain number of frames per seconds (this.fps), until this.maxFrames is hit.
  • For each frame, Shader.run(…) will be called for each pass necessary (this.numPassesPerFrame). Certain effects will require more passes than other, for example, the gaussian blur implementation will require 2 passes.

The Shader object, the core of which is within Shader.run(…),

// Shader object
// Note: Shader.<shader_name>.numPassesRequired must be defined
var Shader = {};
Shader.run =
function (ctxSource, ctxDest, width, height, shaderFunc, passNum, frameNum, maxFrames)
{
//
// netscape.security.PrivilegeManager.enablePrivilege("UniversalBrowserRead"); // REMOVE ME BEFORE DEPLOYMENT
//

var bufWrite = ctxDest.getImageData(0, 0, width, height);

var imageData = null;
if (passNum == 1 && frameNum == 1) {
imageData = ctxSource.getImageData(0, 0, width, height);
}
else {
imageData = ctxDest.getImageData(0, 0, width, height);
}

for (var y = 0; y < height; y++) {

for (var x = 0; x < width; x++) {

var index = (x + y * imageData.width) << 2;
shaderFunc(imageData, bufWrite, index, x, y, imageData.data[index + 0], imageData.data[index + 1], imageData.data[index + 2], imageData.data[index + 3], passNum, frameNum, maxFrames);
}
}

ctxDest.putImageData(bufWrite, 0, 0);
}

(The UniversalBrowserRead privilege is necessary to run this locally in Firefox)

Shader.run(…) will setup the source and destination buffers, iterate over every pixel, compute the pixel index, get the pixel color, call shaderFunc(…) with all the necessary params, and finally commit any changes to the destination buffer (bufWrite). Note, the source canvas is only used for the first frame, first pass; in all other cases whatever is rendered on the destination canvas is used. This allows for certain effects (such as the gaussian blur) in which the effect can be progressively applied again and again, in a feedback loop, to produce updated iterations of the effect (in the case of a gaussian blur, the image is blurred more and more).

The code presented so far lays the framework to allow for writing a shader, plugging it into the system, and watching the result. Before, getting to the more complex gaussian blur filter, here’s a much simpler one: a single-pass, per-pixel image fade-in.

// fade in shader
Shader.fadeInShader =
function (imageData, bufWrite, index, x, y, r, g, b, a, passNum, frameNum, maxFrames)
{
var dt = frameNum/maxFrames; // [0, 1]
GraphicsCore.setPixel(bufWrite, index, r, g, b, dt * 255);
}
Shader.fadeInShader.numPassesRequired = 1;

(Shader..numPassesRequired is required for every shader, as FXController will query the value to determine how many times to call Shader.run(…) per frame.)

The shader function allows us to define what the final color of a pixel will be, given a set of input parameters, on a pixel-by-pixel basis, the core of what a pixel shader system is and allowing for a amazing degree of flexibility.

Finally, the gaussian blur shader. I won’t go into too many details here. If you’re interested in how a guassian blur is actually done, this article on gamedev.net is probably the best out there (esp. for transitioning from theory to practice), and the code here is almost a direct translation of what’s up there. Also note that bitshifts are used to do the power-of-2 divisions and multiplications.

// gaussian blur filter
Shader.gaussFact = Array(1, 6, 15, 20, 15, 6, 1);
Shader.gaussSum = 64;
// not used, >> 6 bitshift used in Shader.gaussBlur()
Shader.gaussWidth = 7;

Shader.gaussBlur =
function (imageData, bufWrite, index, x, y, r, g, b, a, passNum, frameNum, maxFrames)
{
if (passNum == 1 && (x <= 0 || x >= imageData.width - 1)) {
GraphicsCore.setPixel(bufWrite, index, r, g, b, a);
return;
}

if (passNum == 2 && (y <= 0 || y >= imageData.height - 1)) {
GraphicsCore.setPixel(bufWrite, index, r, g, b, a);
return;
}

var readBuf = imageData;
var writeBuf = bufWrite;

var sumR = 0;
var sumG = 0;
var sumB = 0;
var sumA = 0;

for (var k = 0; k < Shader.gaussWidth; k++) {

var nx = x;
var ny = y;

if (passNum == 1) { nx = (x - ((Shader.gaussWidth - 1) >> 1) + k); }
else if (passNum == 2) { ny = (y - ((Shader.gaussWidth - 1) >> 1) + k); }
else { }

// wrap around if we're trying to read pixels beyond the edge
if (nx < 0) { nx = readBuf.width + nx; }
if (ny < 0) { ny = readBuf.height + ny; }
if (nx >= readBuf.width) { nx = nx - readBuf.width; }
if (ny >= readBuf.height) { ny = ny - readBuf.height; }

var pxi = (nx + ny * readBuf.width) << 2;
var pxR = readBuf.data[pxi];
var pxG = readBuf.data[pxi + 1];
var pxB = readBuf.data[pxi + 2];
var pxA = readBuf.data[pxi + 3];

// little hack to make alpha=0 pixels look a bit better
// Note, the proper way to handle the alpha channel is to premultiply, blur, "unpremultiply"
if (pxA == 0) {
pxR = 255;
pxG = 255;
pxB = 255;
pxA = 255;
}

sumR += pxR * Shader.gaussFact[k];
sumG += pxG * Shader.gaussFact[k];
sumB += pxB * Shader.gaussFact[k];
sumA += pxA * Shader.gaussFact[k];
}

GraphicsCore.setPixel(writeBuf, index, sumR >> 6, sumG >> 6, sumB >> 6, sumA >> 6);
}
Shader.gaussBlur.numPassesRequired = 2;

The blur is done with a 3×3 convolution filter, over 2 passes. In the first pass, neighboring pixels are sampled and blurred along the x-axis. In the second pass, the same is done along the y-axis.

A few simple conditionals allow for wrapping around and sampling from the other side of the bitmap, if there’s an attempt to sample beyond the edges.

Note the little hack for transparent/translucent pixels; this is not the proper way to do this (and simply makes the error more grey-ish instead of black-ish), but I didn’t want to deal with premultiplying the alpha, so I’ve left it out.

The demo + all code is up @ http://aautar.digital-radiation.com/HTML5-BlurFX/

Extracting transparency

From time to time, I’ve run across the problem of trying to get rid of a white background of an image with a design that’s mostly the same shade throughout. The hard part is not getting rid of the white background, but getting all the “transition pixels” (i.e. those that allow the design’s edges to gradually fade into the background) to have a somewhat accurate alpha (i.e. transparency) value, so that the design can then be taken and blended nicely atop an arbitrary background without the very common halo effect; this is shown below, trying to remove a white background with Photoshop’s magic wand.

halo problem with magic wand

There are ways to mitigate the issue shown above in Photoshop (see post), but none that are truly simple to the point where they can be done with a click of the mouse. This isn’t really a problem with Photoshop; after all, PS is made to be a general solution and what I’m presenting here is a very specific case.

Anyways, I finally stumbled across the idea of using a user-defined background color and foreground color, and using some bit of magic to interpolate between the values to generate a valid alpha channel. My first instinct was to compute the saturation value of a pixel and use it to find an alpha value for the pixel. However, after a bit of investigation, I realized this wasn’t the correct approach. From Wikipedia’s article on the HSL and HSV color spaces,

There are two factors that determine the “strength” of the saturation. The first is the distances between the different RGB values. The closer they are together, the more the RGB values neutralize each other, and the less the hue is emphasized, thus lowering the saturation. (R = G = B means no saturation at all.) The second factor is the distance the RGB values are from the midpoint. This is because the closer they are to 0, the darker they are until they are totally black (and thus no saturation); and, the closer they get to MAX value, the brighter they are until they are until totally white (and once again no saturation).

Note that grayscale pixels (R = G = B) are considered totally unsaturated, and this could easily lead to problematic cases – definately cases of a black design on a white background.

Moving on, I realized that lightness was a better indicator and it was surprisingly easy to calculate: l = 0.5(max + min). I decided to use a fixed background color of white, just to make things easier, so based upon the lightness of the background (1.0) and foreground color (supplied by the user), I computed the minimum (minL = lightness of the darker, foreground color) and computed the lightness value of each pixel in the image. In general, lightness values should increase as you go from the foreground color to the background color and they should be in the range [minL, 1]. I then did a simple linear scale to the [0,1] range, and did a few checks for pixels that were outside the [0,1] range (caused rogue pixels that were darker than the foreground color). I then computed the alpha value, alpha = 1.0 – l, and that was it. You can see the result below.

transparency extract

transparency extract 2

It’s not perfect. If you zoom in, you will see a white-ish halo, but it’s certainly good enough for many cases. The algorithm could also be refined by replacing the simple linear scaling from [0, minL] to [0,1] with a more “aggressive” function, skewing the lightness values toward 1.0, which could minimize or possibly eliminate the halo effect.

Will post code and application soon. Hopefully, I can also spare some time to work on improving this.