Voxel Build
A little model builds itself out of cubes, from the ground up: each cube drops into place with a bounce while the whole thing turns slowly. Loop it and, once built, the cubes float away and the next model builds: a tree, a toadstool, then a heart.
How to use it
- Paste the snippet just before
</head>. - Add
data-voxel-buildto a box with a height.
Options
Add any of these attributes to the same element.
data-shape | Which models, in turn: any of tree, mushroom, heart and house, separated by commas. Default tree,mushroom,heart. |
data-voxels | Your own model instead, as cubes separated by semicolons, each x,y,z,colour with z going up, e.g. 0,0,0,#7c3aed;0,0,1,#a78bfa. |
data-loop | Take each model apart once built, and build the next. |
data-build | How long a model takes to build. Default 2600 ms. |
data-hold | How long it stays built before coming apart, when looping. Default 2400 ms. |
data-speed | How fast it turns. Default 1; 0 stands it still. |
Drawn on a canvas, paused when off screen. With reduced motion the first model is shown built.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Voxel Build: a little 3D model builds itself out of cubes, layer by
// layer from the ground up, each cube dropping into place with a bounce,
// while the model turns slowly. Loop it and the cubes float away and the
// next model builds.
// Usage: <div data-voxel-build data-loop style="height: 320px"></div>
// Optional: data-shape (comma list of tree, mushroom, heart, house; default
// "tree,mushroom,heart"), data-voxels (your own model instead: "x,y,z,colour"
// cubes separated by semicolons, z up, e.g. "0,0,0,#7c3aed;0,0,1,#a78bfa"),
// data-loop (take it apart and build the next one), data-build (ms to build
// a model, default 2600), data-hold (ms it stays built, default 2400),
// data-speed (how fast it turns, default 1; 0 to stand still).
var DEFAULTS = { shape: 'tree,mushroom,heart', build: 2600, hold: 2400, speed: 1 };
var PITCH = 0.6; // how far we look down, radians
var css =
'[data-voxel-build]{position:relative;overflow:hidden}' +
'.vxb-canvas{position:absolute;inset:0;width:100%;height:100%;display:block}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
// A repeatable "random" number for each cell, so models look the same every time.
function hash(x, y, z) {
var n = Math.sin(x * 127.1 + y * 311.7 + z * 74.7) * 43758.5453;
return n - Math.floor(n);
}
function pick(list, x, y, z) { return list[Math.floor(hash(x, y, z) * list.length)]; }
var SHAPES = {
tree: function () {
var v = [], x, y, z;
for (z = 0; z < 3; z++) v.push([0, 0, z, '#92400e']);
for (x = -3; x <= 3; x++) for (y = -3; y <= 3; y++) for (z = 2; z <= 7; z++) {
var r = (x * x + y * y) / 7.6 + (z - 4.4) * (z - 4.4) / 6;
if (r <= 1 && !(x === 0 && y === 0 && z === 2)) v.push([x, y, z, pick(['#16a34a', '#22c55e', '#15803d', '#4ade80'], x, y, z)]);
}
return v;
},
mushroom: function () {
var v = [], x, y, z;
for (x = -1; x <= 0; x++) for (y = -1; y <= 0; y++) for (z = 0; z < 3; z++) v.push([x, y, z, '#fef3c7']);
for (z = 3; z <= 5; z++) {
var r = 3.7 * Math.sqrt(1 - Math.pow((z - 2.6) / 3.2, 2));
for (x = -4; x <= 3; x++) for (y = -4; y <= 3; y++) {
var dx = x + 0.5, dy = y + 0.5;
if (dx * dx + dy * dy <= r * r) v.push([x, y, z, hash(x, y, z) < 0.16 ? '#fff' : pick(['#ef4444', '#dc2626'], x, y, z)]);
}
}
return v;
},
heart: function () {
var rows = ['.XX.XX.', 'XXXXXXX', 'XXXXXXX', '.XXXXX.', '..XXX..', '...X...'];
var v = [];
rows.forEach(function (row, r) {
row.split('').forEach(function (c, i) {
if (c !== 'X') return;
for (var y = 0; y < 2; y++) v.push([i - 3, y, 5 - r, pick(['#ec4899', '#f472b6', '#db2777'], i, y, r)]);
});
});
return v;
},
house: function () {
var v = [], x, y, z;
for (x = -2; x <= 2; x++) for (y = -2; y <= 1; y++) for (z = 0; z < 3; z++) {
if (x > -2 && x < 2 && y > -2 && y < 1 && z > 0) continue; // hollow inside
var door = y === 1 && x === 0 && z < 2, win = (x === 2 && y === -1 && z === 1);
if (door) continue;
v.push([x, y, z, win ? '#fde68a' : pick(['#f5f3ff', '#ede9fe'], x, y, z)]);
}
for (z = 3; z <= 5; z++) for (y = -3 + (z - 3); y <= 2 - (z - 3); y++) for (x = -2; x <= 2; x++) {
if (y === -3 + (z - 3) || y === 2 - (z - 3) || z === 5) v.push([x, y, z, pick(['#7c3aed', '#6d28d9'], x, y, z)]);
}
return v;
}
};
function parse(text) {
return text.split(';').map(function (c) {
var p = c.split(',');
return [Math.round(+p[0]), Math.round(+p[1]), Math.round(+p[2]), (p[3] || '#7c3aed').trim()];
}).filter(function (p) { return !isNaN(p[0] + p[1] + p[2]); });
}
function rgb(hex) {
hex = hex.replace('#', '');
if (hex.length === 3) hex = hex.replace(/./g, '$&$&');
var n = parseInt(hex, 16);
return [n >> 16 & 255, n >> 8 & 255, n & 255];
}
function tint(c, f) {
return 'rgb(' + c.map(function (v) { return Math.round(f > 1 ? v + (255 - v) * (f - 1) : v * f); }).join(',') + ')';
}
// The six-ish faces of a unit cube: normal, then corners.
var FACES = [
[[0, 0, 1], [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]]],
[[1, 0, 0], [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]]],
[[-1, 0, 0], [[0, 0, 0], [0, 1, 0], [0, 1, 1], [0, 0, 1]]],
[[0, 1, 0], [[0, 1, 0], [1, 1, 0], [1, 1, 1], [0, 1, 1]]],
[[0, -1, 0], [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]]]
];
function model(list) {
var xs = list.map(function (v) { return v[0]; }), ys = list.map(function (v) { return v[1]; }), zs = list.map(function (v) { return v[2]; });
var cx = (Math.min.apply(null, xs) + Math.max.apply(null, xs) + 1) / 2;
var cy = (Math.min.apply(null, ys) + Math.max.apply(null, ys) + 1) / 2;
var z0 = Math.min.apply(null, zs);
var cells = {}, R = 0, top = 0;
var vox = list.map(function (v) {
var o = { x: v[0] - cx, y: v[1] - cy, z: v[2] - z0, c: rgb(v[3]), key: v[0] + ',' + v[1] + ',' + v[2], gx: v[0], gy: v[1], gz: v[2] };
cells[o.key] = o;
R = Math.max(R, Math.hypot(o.x, o.y), Math.hypot(o.x + 1, o.y), Math.hypot(o.x, o.y + 1), Math.hypot(o.x + 1, o.y + 1));
top = Math.max(top, o.z + 1);
return o;
});
// Build order: ground up, and round each layer in a sweep.
vox.sort(function (a, b) { return a.z - b.z || Math.atan2(a.y + 0.5, a.x + 0.5) - Math.atan2(b.y + 0.5, b.x + 0.5); });
vox.forEach(function (v, i) { v.i = i; });
return { vox: vox, cells: cells, R: R, top: top };
}
function bounce(t) {
var n = 7.5625, d = 2.75;
if (t < 1 / d) return n * t * t;
if (t < 2 / d) return n * (t -= 1.5 / d) * t + 0.75;
if (t < 2.5 / d) return n * (t -= 2.25 / d) * t + 0.9375;
return n * (t -= 2.625 / d) * t + 0.984375;
}
function setup(el) {
var d = el.dataset;
var models = d.voxels ? [model(parse(d.voxels))] :
(d.shape || DEFAULTS.shape).split(',').map(function (n) { return SHAPES[n.trim()]; })
.filter(Boolean).map(function (f) { return model(f()); });
if (!models.length || !models[0].vox.length) return;
var canvas = document.createElement('canvas');
canvas.className = 'vxb-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var build = d.build !== undefined ? +d.build : DEFAULTS.build;
var hold = d.hold !== undefined ? +d.hold : DEFAULTS.hold;
var speed = d.speed !== undefined ? +d.speed : DEFAULTS.speed;
var loop = d.loop !== undefined && d.loop !== 'false';
var w = 0, h = 0, dpr = 1;
// phase: 'build' then 'hold' then (looping) 'leave'; t counts ms within it.
var s = { m: 0, phase: 'build', t: 0, yaw: 0.6 };
var DROP = 450, LEAVE = 500;
function resize() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth;
h = el.clientHeight;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
}
// How far each cube is from its place (lift, in cubes) and how solid it is.
function state(v, M) {
var n = M.vox.length;
if (s.phase === 'build') {
var stag = Math.max(0, (build - DROP) / n);
var p = Math.max(0, Math.min(1, (s.t - v.i * stag) / DROP));
return { lift: (1 - bounce(p)) * 3.5, a: Math.min(1, p * 4), done: p >= 1 };
}
if (s.phase === 'leave') {
var st = Math.max(0, (900 - LEAVE) / n);
var q = Math.max(0, Math.min(1, (s.t - (n - 1 - v.i) * st) / LEAVE));
return { lift: q * q * 6, a: 1 - q, done: q === 0 };
}
return { lift: 0, a: 1, done: true };
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
var M = models[s.m];
var cy = Math.cos(s.yaw), sy = Math.sin(s.yaw), cp = Math.cos(PITCH), sp = Math.sin(PITCH);
var span = 2 * M.R + 1;
var scale = Math.min(w * 0.82 / span, h * 0.7 / (M.top * cp + span * sp));
var ox = w / 2, oy = h / 2 + (M.top * cp) / 2 * scale;
function proj(x, y, z) {
var X = x * cy - y * sy, Y = x * sy + y * cy;
return [ox + X * scale, oy + (Y * sp - z * cp) * scale, Y * cp + z * sp];
}
// A soft shadow on the ground.
ctx.save();
ctx.translate(ox, oy);
ctx.scale(1, sp);
var g = ctx.createRadialGradient(0, 0, 0, 0, 0, M.R * scale * 1.15);
g.addColorStop(0, 'rgba(60,20,110,.22)');
g.addColorStop(1, 'rgba(60,20,110,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(0, 0, M.R * scale * 1.15, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
var list = [];
M.vox.forEach(function (v) {
var st = state(v, M);
if (st.a <= 0) return;
var z = v.z + st.lift;
var c = proj(v.x + 0.5, v.y + 0.5, z + 0.5);
list.push({ v: v, st: st, z: z, depth: c[2] });
});
list.sort(function (a, b) { return a.depth - b.depth; });
ctx.lineJoin = 'round';
ctx.lineWidth = Math.max(0.5, scale * 0.04);
list.forEach(function (o) {
var v = o.v;
ctx.globalAlpha = o.st.a;
FACES.forEach(function (f) {
var n = f[0];
// Facing away from us?
var nY = n[0] * sy + n[1] * cy;
if (nY * cp + n[2] * sp <= 0.001) return;
// Hidden behind a settled neighbour?
var nb = M.cells[(v.gx + n[0]) + ',' + (v.gy + n[1]) + ',' + (v.gz + n[2])];
if (nb && o.st.done && o.st.a >= 1 && s.phase !== 'leave' && state(nb, M).done) return;
var light = n[2] ? 1.12 : 0.62 + 0.26 * (n[0] * cy - n[1] * sy + 1) / 2;
ctx.fillStyle = tint(v.c, light);
ctx.strokeStyle = tint(v.c, light * 0.86);
ctx.beginPath();
f[1].forEach(function (k, i) {
var p = proj(v.x + k[0], v.y + k[1], o.z + k[2]);
if (i) ctx.lineTo(p[0], p[1]); else ctx.moveTo(p[0], p[1]);
});
ctx.closePath();
ctx.fill();
ctx.stroke();
});
});
ctx.globalAlpha = 1;
}
resize();
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
s.phase = 'hold';
draw();
if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
return;
}
if (window.ResizeObserver) new ResizeObserver(function () { resize(); draw(); }).observe(el);
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(50, now - last) : 16;
last = now;
s.yaw += dt / 1000 * 0.45 * speed;
s.t += dt;
if (s.phase === 'build' && s.t > build + 200) { s.phase = 'hold'; s.t = 0; }
else if (s.phase === 'hold' && loop && s.t > hold) { s.phase = 'leave'; s.t = 0; }
else if (s.phase === 'leave' && s.t > 900 + 100) {
s.phase = 'build'; s.t = 0;
s.m = (s.m + 1) % models.length;
}
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}, { threshold: 0.2 }).observe(el);
}
function init() {
document.querySelectorAll('[data-voxel-build]').forEach(function (el) {
if (el._vxb) return;
el._vxb = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-voxel-build data-loop style="height: 320px"></div>
<!-- or your own model: -->
<div data-voxel-build data-voxels="0,0,0,#7c3aed;1,0,0,#7c3aed;0,0,1,#a78bfa" style="height: 320px"></div>