]> Depicting the colour cube

Depicting the colour cube

In 2006 some of my colleagues at Opera were preparing our SVG sub-system for wider public deployment and encouraged me to play with it. (SVG is the W3C's XML-based image format, Scalable Vector Graphics.) One of the toys I got it into my head to build was a spinning colour cube, which required much thought and careful working out. Since I needed to write down my reasoning so I'd know how to do it right, I decided to make a web page describing the process. This page includes some SVG images inline: the text may sometimes presume you can see them, but I'll try to remember that you might be reading this with a browser which doesn't support SVG.

The full range of colours displayable on a computer monitor can be represented by a cube, in three dimensions, whose sides are of length 256: the co-ordinates of a point of the cube are read as red, green and blue intensities. The corner whose co-ordinates are all zero is black; the corner opposite it is white, having all co-ordinates equal to 256; the diagonal connecting them is grey, of shade varying gradually from one to another. The edges out of the black corner are red, green and blue: each black at that corner's end and growing lighter along the edge. The edges into the white corner are cyan, magenta and yellow, each at full intensity at one end but washing out to white at their shared corner. The cube's other six edges vary from blue to cyan to green to yellow to red to magenta and back to blue, variously. The faces spanned by these edges have complicated colour variation that I want to implement as SVG colour gradients.

I chose to depict that cube, spinning about its grey axis, as if seen from a position in three dimensions a moderate distance outside the cube in the plane which bisects the grey axis. Rather than spinning the cube, we can describe this as the cube being fixed but our observer moving along a circle in that plane, always looking towards the cube; this lets us describe the cube (and surrounding space) using colour co-ordinates r, g and b.

Co-ordinates and imaginary circles

The plane which bisects the grey axis satisfies r+g+b = 3×128 = 384. The grey axis meets it at the point [r,g,b] = [128,128,128]. Two vectors in the plane are [1,−1,0] and [0,1,−1]; their lengths are both &sqrt;2; their inner product is −1, so the angle between them has cosine −1/2: this angle is a third of a turn. Adding half of one to the other will yield a vector within the plane at right angles to the one we halved. Thus we can use [1,−1,0] and [.5,.5,−1] as an orthogonal basis in the plane, with lengths &sqrt;2 and &sqrt;(3/2); yielding [1,−1,0]/&sqrt;2 and [1,1,−2]/&sqrt;6 as an orthonormal basis.

Let our circle have radius H: it is then given by [r,g,b] in (| 128.[1,1,1] +H.Cos(t).[1,−1,0]/&sqrt;2 +H.Sin(t).[1,1,−2]/&sqrt;6 ←t :{angles}). There are matching circles, in planes perpendicular to the grey axis, through the red, green and blue corners, on the one hand, and through the cyan, magenta and yellow corners on the other. These have centres 256.[1,1,1]/3 and 512.[1,1,1]/3 and radii 256.&sqrt;(2/3); our observer shall see these corner-circles as ellipses, one on each side of the centre-plane. We need H >256.&sqrt;(2/3) = 512/&sqrt;6 so as to be further from the axis than these circles (otherwise, in our image, they'll get mapped to hyperbolae instead of ellipses).

We are looking along a radius, in direction Cos(t).[−1,1,0]/&sqrt;2 +Sin(t).[−1,−1,2]/&sqrt;6, at the cube; we need to draw the cube's projection, radially towards us, onto a plane perpendicular to this ray. One direction perpendicular to the ray is parallel to the grey axis, [1,1,1]; we shall use this direction as the x-axis of our drawing. The y-axis is then the image of the centre-plane, in which our view-point lies: its direction is given by the tangent to our circle, Sin(t).[−1,1,0]/&sqrt;2 +Cos(t).[1,1,−2]/&sqrt;6. Which plane we chose, in this case, only affects the depiction by an over-all scaling.

We can now construct an orthonormal basis describing the co-ordinates relevant to our projection in terms of our [r,g,b] co-ordinates. We have x varying parallel to the grey axis, y varying at right angles to it in our image plane and z varying parallel to the ray from our view-point to the cube's centre, 128.[1,1,1]. The gradients dx, dy and dz may thus be expressed (in terms of dr, dg and db) as

Vectors in the co-ordinate directions, Ex, Ey and Ez, have the same co-ordinates in [r,g,b] co-ordinates as these. If we subtract the position of our view-point, 128.Ex.&sqrt;3 −H.Ez, from the position of some feature of our cube, we get the vector from our view-point to that feature; if we contract this vector with each of dx, dy and dz we get the feature's x, y and z co-ordinates; it then suffices to scale these by some constant divided by the z co-ordinate to obtain the actual x and y co-ordinates we wwant to use in our image plane. It remains to chose H and the distance of our image plane from the grey axis.

Over-all scale

Initially, let's use the plane at distance 256 from the grey axis as image-plane (any other would merely introduce some over-all scaling to the image). The ray from our view-point to the cube's centre meets this at 128.[1,1,1] +256.Cos(t).[1,−1,0]/&sqrt;2 +256.Sin(t).[1,1,−2]/&sqrt;6 = 128.Ex.&sqrt;3 −256.Ez; this is the centre-point of our image (and origin of its [x,y] co-ordinates).

We need 313.5 < 128.&sqrt;6 < H if we're to always be able to see both black and white corners (the keen reader is welcome to verify that, but I'm skipping the derivation here). Furthermore, we really want to see faces sensibly, so we want the black and white corners to be outside the two corner-circles' projected ellipses by a margin comparable with the width of these ellipses and the space between them. The actual length of the grey axis is 256.&sqrt;3, and its projection onto the image plane is shrunk in the same proportion as that image is nearer to our observer than is the axis it represents. Our observer is at radius H and the image plane is 256 from the axis, so the relevant ratio is (H−256)/H, for an image width of (1 −256/H).256.&sqrt;3.

The actual separation of the two corner-circles is 256/&sqrt;3 and their radii are 256.&sqrt;(2/3); so their points most distant from the observer appear in the image plane (the closest approach of the two ellipses) a distance (H −256).256/(H.&sqrt;3 +256.&sqrt;2) apart, while the nearest points appear (H −256).256/(H.&sqrt;3 −256.&sqrt;2) apart. The minor axis of each ellipse is thus half the difference,

(H −256).128.(1/(H.&sqrt;3 −256.&sqrt;2) −1/(H.&sqrt;3 +256.&sqrt;2))
= (H −256).128.(512.&sqrt;2)/(3.H.H −217)
= (H −256).(&sqrt;2)/(3.(H/256)2 −2)

The apparent separation between black or white corner and the nearest point of a corner-circle is likewise half the difference between the separation of the circles' nearest points and the full image length:

(H −256).128.(&sqrt;3/H −1/(H.&sqrt;3 −256.&sqrt;2))
= (H −256).256.(H/256 −&sqrt;(3/2))/(H.&sqrt;3/256 −&sqrt;2)/H

We thus divide the x-axis into five parts: each end's separation from the ellipse, the semi-minor axis of the ellipse and the separation of the ellipses. Taking 1 < h = H/256, and leaving aside a factor of 256.(h−1).(&sqrt;2)/(3.h.h −2) from each, these parts are in the ratio:

first +&sqrt;(3/2)/h = last +1/2; so first = last when h = &sqrt;6; the ratios are then 2:1:2; (so the full pattern is 2:1:2:1:2). The shared factor is then 32.(&sqrt;3 −1/&sqrt;2) ≈ 32.8 for a total image width of 262.4, i.e. eight times this shared factor. Sounds tidy: let's take H = 256.&sqrt;6 ≈ 627.

Now to the y-span. There's a pair of planes, tangent to both of the corner-circles, through our observer point: they meet one another in a line parallel to the grey axis through this point. Each also meets the image plane in a line, parallel to the x-axis; one of these serves as the top of our picture, the other as the bottom. In the plane of one of the corner-circles, the grey axis and the axis parallel to it through the observer appear as points a distance H apart; each of our tangent planes appears as a line through the latter touching our circle; if we connect the point where it touches to the circle's centre, we form a right angle triangle with hypotenuse H. The chord connecting where the two tangents touch the circle is parallel to our y-axis; taken together with the matching chord of the other corner-circle, it defines a plane parallel to our image plane.

The marked angles are equal and their sine is the radius of our corner circle divided by H; i.e. 256.&sqrt;(2/3) / H; which, with H = 256.&sqrt;6, is just 1/3; its cosine is thus 2.(&sqrt;2)/3. Consequently, the left dashed line is 1/3 of the circle's radius to the right of the circle's centre, so H −256.&sqrt;(2/3)/3 away from the observer's axis. The tangent and radius meet 256.&sqrt;(2/3)×2.(&sqrt;2)/3 = 1024/3/&sqrt;3 above the centre-line, projecting to a y-coordinate in the image plane of

= (1024/3/&sqrt;3).(H −256)/(H −256.&sqrt;(2/3)/3)
= 1024.(h −1)/(3.h.&sqrt;(3/2) −1)/&sqrt;2
= 128.(&sqrt;3 −1/&sqrt;2) ≈ 131.2

for a total diagram height of 262.4, exactly matching our diagram's width. (In general the height/width ratio is 2.h/&sqrt;(9.h.h/2 −3), which is 1 precisely if h.h = 6.)

We're getting factors of (&sqrt;3 −1/&sqrt;2), which is almost but not quite exactly one, and they're just going to proliferate, so let's shift the image plane a bit to remove this factor. We're viewing the cube from 256.&sqrt;6 away from its centre, projected onto a plane 256 away; we need to adjust the latter so that the separation of these two, 256.(&sqrt;6 −1), is reduced by the factor (&sqrt;3 −1/2), yielding separation 256.&sqrt;2 and putting the image plane 256.(&sqrt;3 −1).&sqrt;2 away from the cube's centre. In this plane, our image shall have width and height 256. The image co-ordinates of the point at [r,g,b] in colour-space can be computed by contracting [r,g,b] +128.(2.Ez.&sqrt;2 −Ex).&sqrt;3 with each of dx, dy and dz; then dividing 256.&sqrt;2 by the last and multiplying this ratio by each of the first two, yielding x and y.


For the faces, I need to work out how to make a two-dimensional colour gradient; and hit my first problem. A colour gradient is one-dimensional: the colour varies along one direction but is constant along another (usually perpendicular) direction. I need one colour component varying one way and another componet varying the other way. I can combine two colour gradients varying in different directions by putting a semi-transparent one on top of an opaque one:

but it leaves me (for perfectly good reasons) with only half-saturated colours at the borders. So I asked my friendly experts for help and Erik suggested I see whether there are filter effects that might help. After much reading, I concluded that an arithmetic composite should do the trick:

and (once Fredrik had told me to enable-background) that works a treat :-)

We can even extend this trick to three variables and show the triangles you'd see if you cut through the colour cube in the plane of each of the corner ellipses discussed above. Whether user agents shall display this as intended is another matter…


So we have

and it's time to add some edges. There are basically two edges: one joining an end of the grey axis to the nearer corner-ellipse and one joining the two corner ellipses: there are six of each. To work out how to draw these, we'll need to compute their x, y and z co-ordinates and scale – as described above – to obtain the x and y co-ordinates of the point's image in our image plane.

The red corner, [256,0,0], is on the left corner-circle. Subtracting our view-point yields 128.([2,0,0] −Ex.&sqrt;3 +2.Ez.&sqrt;6); contracting this with dx, y and dz yields

128.([2,0,0] −Ex.&sqrt;3 +2.Ez.&sqrt;6)·dx
= 128.(2 −3)/&sqrt;3
= −128/&sqrt;3
128.([2,0,0] −Ex.&sqrt;3 +2.Ez.&sqrt;6)·dy
= 128.(−2.Sin(t)/&sqrt;2 +2.Cos(t)/&sqrt;6)
= 256.(Cos(t) −Sin(t).&sqrt;3)/&sqrt;6
128.([2,0,0] −Ex.&sqrt;3 +2.Ez.&sqrt;6)·dz
= 128.(−2.Cos(t)/&sqrt;2 −2.Sin(t)/&sqrt;6 +2.&sqrt;6)
= 256.(6 −Cos(t).&sqrt;3 −Sin(t))/&sqrt;6

and we need to scale these by the common factor which makes the last of them 256.&sqrt;2. Note that Cos(t).&sqrt;3 +Sin(t) = 2.Sin(t +turn/6), since turn/6 (a.k.a. 60 degrees) has sine (&sqrt;3)/2 and cosine 1/2; likewise, Cos(t) −Sin(t).&sqrt;3 = 2.Cos(t +turn/6). Thus our z component is 256.(3 −Sin(t +turn/6)).&sqrt;(2/3) so our desired scaling is (&sqrt;2) / (3 −Sin(t +turn/6)) / &sqrt;(2/3) = (&sqrt;3) / (3 −Sin(t +turn/6)), yielding co-ordinates in our image plane

= −128.(&sqrt;3) / (3 −Sin(t +turn/6))/&sqrt;3
= −128 / (3 −Sin(t +turn/6))
= 256.(Cos(t) −Sin(t).&sqrt;3).(&sqrt;3) / (3 −Sin(t +turn/6))/&sqrt;6
= 256.Cos(t +turn/6).(&sqrt;2) / (3 −Sin(t +turn/6))

These plainly depend on t only via t+turn/6 and we can expect the other two points on the left circle to follow the same dependency but with t+turn/6 replaced by the angles turn/3 either side of it, t−turn/6 and t±turn/2. The right circle's corners shall be similar but with x negated (thus making it positive) and angles t, t+turn/3 and t−turn/3. We've got six points evenly spaced around our two circles (each set of three falling in the gaps of the other), so – when discretizing Sin and Cos, which I really wish SVG didn't leave me needing to do – we'll need to divide the turn into some multiple of six evenly spaced steps.

A few more colour gradients and that lot suffices to give me (albeit missing the fiddly business needed to make the right things pass in front and behind one another, so that it takes a moment to see it right – ignore the crossings of edges and remember that longer edges, and bigger faces, are nearer you) all of the edges of the cube:


This last image, for all its simplicity, is 116 lines of impenetrable gibberish, amounting to almost 23 kB of data. I really wish I could put the animate elements in the defs section and reference them from the places that need them, to save duplication. However, use elements only work on graphical elements, not animations.

This (and other problems I had animating my favourite proof of pythagoras' theorem) would be even better resolved if there was a way to specify, for an attribute of (e.g. a co-ordinate of a point in) one figure, that it be taken from a kindred attribute of some other (possibly non-displayed) figure within the same diagram (i.e. SVG element). I could then make myself an equilateral triangle on one side of the centre-line, set it rotating about its centre at fixed rate, copy it by reflection and a turn/6 rotation to the other side of the centre-line, transform both by shrinking perpendicular to the centre-line (without change parallel to it), so that the circles on which their vertices are moving become the corner-ellipses, and have the lines above specify their end-points by reference to the vertices of these (non-displayed) triangles. For that I'd only need one animate element (on the first triangle) and it'd be a simple fixed-rate rotation – there'd be no need for huge numeric values="…" attributes.


OK, so we know where the corners are going and we can make two-dimensional colour gradients, so we should be able to stick faces on our cube. However, a colour gradient applies itself along the direction of a vector, which is great for a square: but our cube's faces appear as assorted skew quadrilaterals. Since opposite sides aren't parallel, there isn't a single vector for either of a face's two gradients to point along. We can apply a transformation to the gradient, or indeed to the face after applying the gradient, but the transformations available are all linear maps, so preserve parallel-ness of edges. So I'm going to have to bodge the colour gradients a bit; and I'm going to need to combine two rectangles for each face.


So, finally, we just need to add faces with two-way colour gradients and hide the bits that need it; but that'll have to wait for another day.

Valid CSS ? Valid XHTML ? I need to work out how to embed SVG correctly !
Written by Eddy.