We render in 3D, or how to make friends D3 and Three.js

  • Tutorial
If you've already heard of D3 and Three.js , this article may seem interesting to you. It will focus on how to make these libraries work together to create dynamic three-dimensional scenes, using this simple histogram as an example:





Where do the legs grow from?



Some time ago, we at CodeOrchestra experimented with a D3 port on an AS3 / DSL codenamed “D6” (from D3 + 3D). Our port covered only the most basic D3 functions, but was able to work with the popular 3D engines on AS3 “out of the box”. And although we never brought D6 to light, the very idea of ​​using D3 for 3D has not left our minds ever since. Indeed, if you look into the gallery D3 , you will not find a single three-dimensional example. The reason is that D3 is very tuned to work with the browser DOM, and it seems that it does not support fetching arbitrary objects. However, with sufficient motivation, we can force it.


So, let's begin



Let's start with the simplest example of a two-dimensional histogram using D3 (hereinafter the code is adapted from the official lessons of D3 [ 1 ] and [ 2 ], and shortened for readability):

d3.select(".chart")
  .selectAll()
    .data(data)
  .enter().append("div")
    .style("width", function(d) { return d * 10 + "px"; });

This example shows that the main D3 methods accept magic DOM-dependent strings (such as a selector .chartor tag name div) as arguments , which is extremely inconvenient for our purposes. Fortunately, these methods have alternative signatures. These signatures exist for boring things like reuse of samples. We will use them to rewrite our example as follows:

function newDiv() {
    return document.createElement("div");
}
var chart = {
    appendChild: function (child) {
        // эта функция будет вызвана из append() после newDiv()
        return document.getElementById("chartId")
            .appendChild(child);
    },
    querySelectorAll: function () {
        // эта функция будет вызвана из selectAll()
        return [];
    }
}
d3.select( chart )
  .selectAll()
    .data(data)
  .enter().append( newDiv )
    .style("width", function(d) { return d * 10 + "px"; });

As you can see, we 1) told D3 how to create it divexplicitly, and 2) convinced D3 that our chart object is a duck . At the same time, the result of our code has not changed at all.


So what about 3D?



The de facto standard for 3D graphics in JavaScript today is Three.js. If we want to do 3D in D3, we need to convince D3 to work with selections from three-dimensional Three.js objects in the same way. To do this, we will add the following methods to the Object3D prototype:

// эти методы нужны для D3-шных .append() и .selectAll()
THREE.Object3D.prototype.appendChild = function (c) { this.add(c); return c; };
THREE.Object3D.prototype.querySelectorAll = function () { return []; };
// а этот - для D3-шного .attr()
THREE.Object3D.prototype.setAttribute = function (name, value) {
    var chain = name.split('.');
    var object = this;
    for (var i = 0; i < chain.length - 1; i++) {
        object = object[chain[i]];
    }
    object[chain[chain.length - 1]] = value;
}

This is quite enough to create the simplest three-dimensional histogram :

function newBar () {
    return new THREE.Mesh( geometry, material );
}
chart3d = new THREE.Object3D();
// используем D3 для создания 3D столбцов
d3.select( chart3d )
  .selectAll()
    .data(data)
  .enter().append( newBar )
    .attr("position.x", function(d, i) { return 30 * (i - 3); })
    .attr("position.y", function(d, i) { return d; })
    .attr("scale.y", function(d, i) { return d / 10; })



It's all ?



Not at all. In order to use the main feature of D3 - data change processing - we need to review our tricks. First, so that D3 can interpolate the "attribute" values, we need to add the getAttribute method to the Object3D prototype:

THREE.Object3D.prototype.getAttribute = function (name) {
    var chain = name.split('.');
    var object = this;
    for (var i = 0; i < chain.length - 1; i++) {
        object = object[chain[i]];
    }
    return object[chain[chain.length - 1]];
}

Secondly, selectAll () should actually work to build a selection of updated objects. For example, we can implement the selection of heirs of Object3D of a certain type:

THREE.Object3D.prototype.querySelectorAll = function (selector) {
    var matches = [];
    var type = eval(selector);
    for (var i = 0; i < this.children.length; i++) {
        var child = this.children[i];
        if (child instanceof type) {
            matches.push(child);
        }
    }
    return matches;
}

To make our columns dance , now it's enough just to periodically change the data:

var N = 9, v = 30, data = d3.range(9).map(next);
function next () {
    return (v = ~~Math.max(10, Math.min(90, v + 20 * (Math.random() - .5))));
}
setInterval(function () {
  data.shift(); data.push(next()); update();
}, 1500);
function update () {
    // используем D3 для создания и обновления 3D столбцов
    var bars = d3.select( chart3d )
        .selectAll("THREE.Mesh")
        .data(data);
    bars.enter().append( newBar )
        .attr("position.x", function(d, i) { return 30 * (i - N/2); });
    bars.transition()
        .duration(1000)
        .attr("position.y", function(d, i) { return d; })
        .attr("scale.y", function(d, i) { return d / 10; });
}

So, the general principle of pairing D3 with Three.js should be clear to you - we are gradually adding methods sufficient for the D3 functional that interests us to the Object3D prototype. But to consolidate, consider the last version of the histogram, in which we use data binding by key and work with a selection of deleted objects. Add the removeChild method to the Object3D prototype:

THREE.Object3D.prototype.removeChild = function (c) { this.remove(c); }

If you tried now to use the remove () method to select the deleted objects, you would find that nothing is happening. Why? The answer is easy to see in the D3 sources - the remove () method does not use the parentNode of the selection, but tries to remove the object from its immediate parent. To make this possible, we need to adjust our implementation of appendChild ():

THREE.Object3D.prototype.appendChild = function (c) {
    this.add(c);
    // создаём свойство parentNode
    c.parentNode = this;
    return c;
}



Total



And in the end, we got such a beauty :

var N = 9, t = 123, v = 30, data = d3.range(9).map(next);
function next () {
  return {
    time: ++t,
    value: v = ~~Math.max(10, Math.min(90, v + 20 * (Math.random() - .5)))
  };
}
function update () {
	// используем D3 для создания, обновления и удаления 3D столбцов
	var bars = d3.select( chart3d )
		.selectAll("THREE.Mesh")
		.data(data, function(d) { return d.time; });
	bars.transition()
		.duration(1000)
		.attr("position.x", function(d, i) { return 30 * (i - N / 2); })
	bars.enter().append( newBar )
		.attr("position.x", function(d, i) { return 30 * (i - N / 2 + 1); })
		.attr("position.y", 0)
		.attr("scale.y", 1e-3)
	  .transition()
		.duration(1000)
		.attr("position.x", function(d, i) { return 30 * (i - N / 2); })
		.attr("position.y", function(d, i) { return d.value; })
		.attr("scale.y", function(d, i) { return d.value / 10; })
	bars.exit().transition()
		.duration(1000)
		.attr("position.x", function(d, i) { return 30 * (i - N / 2 - 1); })
		.attr("position.y", 0)
		.attr("scale.y", 1e-3)
		.remove()
}

As you can see, D3 does a great job with 3D, if you help it a little, and Three.js does not create any problems in this. Both libraries have their strengths, and I hope that this article has opened the way for you to harmoniously combine them in your future work.

Also popular now: