Smiley Spheres
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
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.
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:
- Hue step — rotates hue around the color wheel. Small steps (5–10°) give similar colors. Larger steps (60–120°) give contrasting colors.
- Saturation step — changes color intensity per step. Negative values go from vivid to pastel.
- Lightness step — changes brightness per step. Positive values go toward white, negative toward dark.
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.
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:
- Brute Force — O(N²). Checks every pair. Zero memory allocation. Fastest when N ≤ 80.
- Sweep & Prune — O(N log N). Sorts bodies by X axis, then checks only overlapping intervals. A middle-ground option.
- Spatial Hash — O(N) amortized. Puts bodies into hash-map buckets by grid cell. Per-frame
Map/Setallocation and GC overhead made it the slowest in dense, fixed-size scenes. - Uniform Grid — O(N) amortized. Uses a flat, pre-allocated array. Fastest from N ≈ 100 onward — over 1,000× faster than Brute Force at N = 10,000.
- Quadtree — O(N log N) average. Recursive quad subdivision. Better than Brute Force at high N (854.1 µs vs 70.4 ms at N = 10,000), but tree traversal is slower than the Uniform Grid's cache-friendly array lookups.
| N | Brute Force | Sweep & Prune | Spatial Hash | Uniform Grid | Quadtree |
|---|---|---|---|---|---|
| 20 | 221 ns | 1.1 µs | 2.0 µs | 2.3 µs | 1.7 µs |
| 50 | 1.5 µs | 3.6 µs | 9.5 µs | 3.2 µs | 6.3 µs |
| 75 | 3.5 µs | 6.0 µs | 15.0 µs | 4.1 µs | 10.1 µs |
| 100 | 5.9 µs | 8.3 µs | 23.0 µs | 4.5 µs | 13.0 µs |
| 500 | 127.7 µs | 74.4 µs | 123.6 µs | 5.4 µs | 46.6 µs |
| 1,000 | 521.9 µs | 196.8 µs | 297.1 µs | 7.1 µs | 85.8 µs |
| 5,000 | 17.6 ms | 1.8 ms | 15.6 ms | 20.1 µs | 385.4 µs |
| 10,000 | 70.4 ms | 6.3 ms | 189.3 ms | 60.2 µs | 854.1 µs |
Show benchmark results
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
-
More emotions
Problem: Only three face types exist — smiley, neutral, and sad.
Solution: Add surprised, angry, and sleepy faces, each with distinct mouth and eye shapes drawn via the
Canvas API. -
Wallpaper generator
Problem: There is no way to save or share a scene — the palette and layout live only in the browser.
Solution: Export the current arrangement as a high-resolution image so users can tune colors and download a tiled or full-bleed wallpaper directly from the playground.
-
More robust physics
Problem: Simple Euler integration and axis-aligned collision response cause visible wobbling and tunneling glitches at high velocities or with many overlapping spheres.
Solution: Improve the custom system — continuous collision detection, proper restitution, and stable stacking — or integrate
Rapier, a ready-made physics engine that handles all of this out of the box. -
Deterministic physics with a seed
Problem:
Math.random()causes every page load to produce a completely different scene.Solution: Use a seeded pseudo-random number generator so layouts are fully reproducible for testing, screenshots, and shareable links.
-
Interactive algorithm comparison chart
Problem: The benchmark table shows raw numbers, but it is hard to see crossover points and scaling curves at a glance.
Solution: Render an interactive log-scale graph of all five algorithms, plotting N on the x-axis and time (µs) on the y-axis. Hovering a point would show the exact value and highlight the crossover where Uniform Grid overtakes Brute Force.
References
- SuperHi — original animation that inspired this project
- Three.js — 3D rendering library
- Deno.bench() — built-in benchmarking tool