🚧 Work in progress — content may change! 🚧

Smiley Spheres

v1.0 27th February 2026
3D Study

Motivation

I saw a collision animation on the SuperHi website. Colored spheres were bouncing off each other inside a box. I wanted to learn how to build similar interactions, so I decided to build my own version as a study.

Tools and technologies

Three.js React Canvas API Deno (Bench) TypeScript GitHub Copilot Claude

How textures are generated

Each sphere needs a texture — a flat image that wraps around its surface. The texture is an equirectangular projection: a canvas with a 2:1 aspect ratio (1024×512 pixels). The face is painted at the center of this canvas, which maps to the front of the sphere when Three.js applies the texture.

The drawing process has three steps. First, the entire canvas is filled with the sphere's color. Second, two circular eyes are drawn. Third, a mouth is added based on the emotion. For a smiley face, the mouth is a downward arc. For a sad face, it is an upward arc (a frown). For a neutral face, it is a straight horizontal line — no arc at all.

The face color (eyes and mouth) is chosen automatically based on the background brightness. Using relative luminance (WCAG formula), if the background is light (luminance > 0.55), the face is drawn in black. If the background is dark, the face is drawn in white. This keeps the face visible on any color.

Below are the three texture types rendered as flat canvases.

Smiley texture
Neutral texture
Sad texture

Single sphere

Here is one sphere with the texture applied. Move the cursor over it — the sphere rotates to follow the pointer, showing how the flat texture wraps around the 3D surface. Use the emotion picker below to switch between smiley, neutral, and sad faces.

Color palette

Spheres do not use random colors. Instead, all colors come from a single palette generated at startup. The algorithm takes one base color, converts it to HSL (hue, saturation, lightness), and produces more colors by shifting each channel by a fixed step:

Saturation and lightness values are clamped to the 0–1 range. Each sphere picks its color by index (wrapping around when there are more spheres than palette entries). The hero scene above uses a 5° hue step starting from yellow-green.

Color playground

Use the sliders to change the base color and step values. The swatch strip shows the generated palette. The scene below updates in real time with the new colors.

Base color
Palette steps

Collision detection benchmarks

The main technical challenge was collision detection. The simplest approach — checking every pair of spheres — has O(N²) complexity. It works for 20–30 spheres but slows down with more. Instead of guessing where the limit is, I benchmarked five different algorithms using Deno.bench() with scene sizes from 20 to 10,000 spheres.

The five algorithms:

Show benchmark results
N = 20
Brute Force 221 ns
Sweep & Prune 1.1 µs
Spatial Hash 2.0 µs
Uniform Grid 2.3 µs
Quadtree 1.7 µs
N = 50
Brute Force 1.5 µs
Sweep & Prune 3.6 µs
Spatial Hash 9.5 µs
Uniform Grid 3.2 µs
Quadtree 6.3 µs
N = 75
Brute Force 3.5 µs
Sweep & Prune 6.0 µs
Spatial Hash 15.0 µs
Uniform Grid 4.1 µs
Quadtree 10.1 µs
N = 100
Brute Force 5.9 µs
Sweep & Prune 8.3 µs
Spatial Hash 23.0 µs
Uniform Grid 4.5 µs
Quadtree 13.0 µs
N = 500
Brute Force 127.7 µs
Sweep & Prune 74.4 µs
Spatial Hash 123.6 µs
Uniform Grid 5.4 µs
Quadtree 46.6 µs
N = 1,000
Brute Force 521.9 µs
Sweep & Prune 196.8 µs
Spatial Hash 297.1 µs
Uniform Grid 7.1 µs
Quadtree 85.8 µs
N = 5,000
Brute Force 17.6 ms
Sweep & Prune 1.8 ms
Spatial Hash 15.6 ms
Uniform Grid 20.1 µs
Quadtree 385.4 µs
N = 10,000
Brute Force 70.4 ms
Sweep & Prune 6.3 ms
Spatial Hash 189.3 ms
Uniform Grid 60.2 µs
Quadtree 854.1 µs

Apple M4, Deno 2.6.10, February 2026. Bold = fastest at that N.

The data shows a clear crossover at N ≈ 80. Below that, Brute Force wins because it has zero setup cost — just a tight loop that fits in the CPU cache. Above that, Uniform Grid dominates because its pre-allocated flat array avoids per-frame memory allocation.

Based on these results, the scene uses an auto mode: Brute Force for ≤ 80 spheres, Uniform Grid above that. This is a data-driven choice, not a guess.

Key takeaway

Always benchmark before optimizing. O(N²) is not automatically the slowest — at low sphere counts, Brute Force was the fastest because it does zero allocation, no sorting, and no hashing. A tight inner loop that fits in the CPU cache beats algorithms with better theoretical complexity but higher constant overhead.

Spatial Hash had the best theoretical complexity — O(N) amortized — but per-frame Map/Set creation triggers garbage collection, making it consistently slower for the scene sizes I actually use. Quadtree scales better than Brute Force (854.1 µs vs 70.4 ms at N = 10,000) but still cannot match the Uniform Grid's cache-friendly array lookups (60.2 µs at the same N).

Deno.bench() made it easy to test all five algorithms side by side with realistic data. The benchmark results directly informed the production code.

As a side note: unsurprisingly, physics simulation is a complex topic. Although I spent a couple of days on this project, I still feel that I barely scratched the surface.

Ideas for future improvements

References