
Every combination at once
BasisEK puts all 147,442 Emoji Kitchen combinations on screen in one browser tab. You can fly from the whole set down to one sticker at full resolution, rearrange all of them by feeling, by base emoji or by color, and search them with a sentence. At full size the stickers are 168 GB of raw pixels; the browser gets 197 MB at startup and draws everything at 60 frames a second.
The stack
| Step | Tool | What it does |
|---|---|---|
| Source | 147,442 Emoji Kitchen PNGs | Google's production stickers, 534 px each |
| Atlases | Python and Pillow | Packs the stickers into 16,384 px squares: one atlas at 40 px a sticker, 13 at 150 px |
| Compression | Basis Universal 1.60 (basisu, ETC1S, quality 150) | 14 KTX2 files, 391.8 MB in all; one 16,384 px atlas would be 1 GB as raw pixels |
| Transcoding | three.js KTX2Loader, the Basis transcoder in 4 WebAssembly workers | Converts each file into a format this GPU reads (ETC2, BC7, DXT5 or PVRTC); about 2 seconds for the small atlas |
| Rendering | three.js 0.181 on WebGL2, instanced meshes, custom shaders | 176 chunks and about 294,000 quads |
| Close view | Google's static CDN and an 8,192 px texture | Streams full-resolution stickers into 256 slots |
| Meaning | SigLIP 2 (SO400M, 384 px) on The Cooper Union's GPU cluster; PCA and t-SNE; NRC emotion lexicons, WordNet, 27 emotion categories | Embeds every sticker, lays out the emotion map and names each sticker's closest feeling |
| Search | A Python server with open_clip and PyTorch, on Modal | Takes a sentence and returns ranked stickers in about a second |
| Hosting | Vite 7; Cloudflare R2, served through the site | A 525.7 MB upload; later code changes are under 1 MB |
The atlases stay compressed on the GPU. The transcoder runs on the CPU and converts each file into a compressed format the GPU samples directly, at 8 bits a pixel, so one 16,384 px atlas costs about 256 MB of video memory. Decoded to RGBA it would cost 1 GB.
Three levels of detail
The design holds only as much texture as the distance needs.
From far away, every sticker comes from one atlas at 40 px each: 44.8 MB, and all 147,442 fit in one texture. Closer in, each chunk switches to its share of 13 atlases at 150 px, 347 MB together. They load only when you approach, and a "Loading sharper stickers" pill appears if one takes longer than 400 ms. Closest, when the camera comes within 12 units, the 256 stickers nearest the center of the screen stream from Google's CDN at full size into 512 px slots of an 8,192 px texture: 8 requests at a time, one upload to the GPU per frame, each sticker fading in over 260 ms. Search results that cover 48 px or more on screen get full resolution at any distance.

Atlases that didn't ship
The first version packed stickers at their full 534 px: 900 to an atlas of 16,020 px, about 164 atlases, loaded two at a time by an impact score as you zoomed in. I halved them to 8,010 px to save memory and then replaced them with the 13 atlases at 150 px, which cover the middle distance at a fraction of the size, with full resolution coming from the CDN.
I also built WebP atlases for the middle distance, Brotli compression on top of KTX2, and encodes with mipmaps through toktx and ktx create. None shipped, and I didn't record measurements for them. The full-size stickers once lived in the repository too, committed in 50 MB batches through Git LFS; hotlinking Google's CDN replaced them.
The first working build requested all 13 middle atlases at startup, half a gigabyte before the first frame. They load on demand now, and startup is 197 MB: the small atlas and six JSON maps.
A bounding box that disabled the tiers
Every chunk's bounding box was hard-coded to 9,999 units in each direction, so the first frame's visibility check would pass. The same box fed the distance test that picks a chunk's tier, and from inside a box that size the camera is always at distance 0. All 176 chunks stayed on the middle tier forever. The 44.8 MB far atlas downloaded on every visit and never drew, and nothing was ever culled.
The value was also written a second time in the per-frame position update, so fixing it where the chunk was built changed nothing. After both fixes, the measured distance to a chunk at the fitted view went from 0 to 912 units.
Aliasing
The fitted view shimmered. Three rounds of texture work went into it before I measured the right thing: at that view each sticker is 0.71 CSS pixels wide. A quarter of a million quads smaller than a pixel produce moiré, and texture filtering doesn't affect it.
Along the way, several fixes did nothing. The renderer's antialias flag is ignored when you hand three.js your own WebGL context. Switching from nearest to linear filtering and capping the pixel ratio at 2 changed little. One reported 36.6% improvement turned out to be false: Vite's file watcher lagged 7 to 34 seconds, so the screenshots showed the previous build. Separately, the shader premultiplied alpha twice, which darkened every sticker's edge.
Three changes fixed it. The far atlas ships without mipmaps, so I render it once into a 4,096 px texture with a 4 by 4 box filter, premultiplied, and let the GPU build mipmaps from that. Rendering supersamples up to 4 times the screen's resolution within a budget of 26 million pixels, so a small window gets the full 4 times and a large retina screen scales back. And any quad smaller than a pixel is drawn at one pixel, with its opacity scaled down to stand for the area it lost. The result runs at 60 frames a second at 6,400 by 4,000.

Supersampling has a limit: 900 stickers inside a 40 pixel cluster are still below what any number of samples can resolve. The next step is a tier above the far atlas that renders each cluster once into its own prefiltered texture and draws one quad per cluster when it's small on screen.
Layouts
There are three arrangements, and every sticker moves to its new place when you switch.
The emotion map places each combination by what it shows. SigLIP 2 embeds every sticker, PCA and t-SNE flatten the embeddings to two dimensions, and a grid assignment gives each sticker its own cell. Each sticker also gets its closest feeling word, from 6,171 emotional adjectives in the NRC lexicons and WordNet and from 27 emotion categories. Snapping the raw t-SNE points straight to a grid left 12,784 combinations without a cell. The final assignment places 147,428 of 147,433, at a median of 1.6 cells from where t-SNE put them and 95% within 11.

By emoji groups the combinations under their base emoji, so each one appears under both of the emoji it combines.
Rainbow sorts all of them by hue, from each sticker's two main colors.

The first motion eased every sprite a fixed fraction of the way each frame. Layouts took 5 to 7 seconds to settle and moved twice as fast on 120 Hz screens. Springs replaced it, and layouts settle in 3.5 to 4.3 seconds at any refresh rate. A 4 second timer that stopped culling during transitions froze chunks halfway; tracking when each chunk has settled replaced it, and in-page search went from 1.8 to 3.6 seconds to about 100 ms.
Frame time
After the layouts shipped, the page lagged at rest. three.js draws any material that's both transparent and double-sided twice, back faces then front, and flags the material for an update before each pass. With 176 chunk meshes, that meant about 340 shader program lookups every frame. Drawing them in a single pass took arrange mode at rest from 184 ms of main thread a second to 35. An animated CSS gradient behind the page cost another 30 ms a second; removing it took idle time from 129 ms to 44.
Search
Type a sentence and every sticker gets a score; the best matches grow, and the slider sets how many are shown. A query runs as the typed text plus five rephrasings, matched against the pictures through SigLIP 2 and against the stickers' names and labels. Each score becomes a z-score, they're summed, and the results are reordered for variety.

Most of the ranking work was measured on a test set and thrown out. Rephrasings alone moved faces from a median rank of 270 to 120 and objects from 75 to 115, worse. Reciprocal rank fusion did no better than summing z-scores. Names and labels at full weight overruled the picture. Reordering only the top 100 for variety left "cool expressions" at 96% 🆒 signs; reordering the top 3,000 buried sibling combinations. Sizing results by 1 + 7p^0.8 made 1,227 of 2,000 results for "angry" twice as large.
The version that shipped puts the right sticker first 42.1% of the time, up from 40.8%, with nDCG@10 at 0.776, up from 0.718. On broad queries the most common emoji's share of the top 24 dropped from 72% to 43%. "Overwhelmed with love" found its best match at rank 211, up from 2,043. Scoring in 32-bit floats instead of 16 added a point of first-place accuracy.
Hosting search
Search first ran only on my Mac. A public page that calls a server on the visitor's own machine triggers Chrome's local network prompt, so visitors never saw the search bar.
I measured a search that runs entirely in the browser against the server's results, over 20 queries. Bag-of-words matched 16% of the server's top 50; compressed with PCA or 8-bit weights, 4 to 8%; exact per-word lists fused for sentences, 20%. A Hugging Face Space failed at creation: Docker Spaces need a paid account since October.
It runs on Modal now. The server ships SigLIP's 1.4 GB text tower, against 4.5 GB for the whole model, and the image embeddings at 334 MB. It scales to zero; the first search after a pause takes about 26 seconds, and the rest take 0.8 to 1.2 seconds, with top 50 results identical to my Mac's.
Loading
On a 40 megabit connection with an empty cache, the page took 45 seconds to show anything, and visitors looked at a blank navy screen. A loading screen now shows a progress bar measured against the expected size of every startup file, which fills over about 41 seconds and lifts at 45.
What's left
The tier above the far atlas, which renders each cluster once, is still unbuilt. The 115 MB of JSON maps were never packed into a binary format. The far atlas has no real mipmaps, and the source PNGs it came from are gone. A layout change costs about 10 ms of CPU a frame, because every sprite's motion runs in JavaScript; moving it into the vertex shader would remove that. A pilot of generated captions raised search quality from 0.788 to 0.808; the full captioning run hasn't happened yet.