Rewritten on 5 August 2026. The version of this page that had been up since September 2025 was wrong in nearly every technical detail. It described a starfield built from a THREE.InstancedMesh of sphere geometry, a 30 degree camera and a GPU picking pass, and claimed instancing took a frame from 500ms to 4ms. None of that was ever in the project. The starfield has been a THREE.Points cloud since the first week, the camera has always been 75 degrees, and the 125x was invented. It was one of a batch of articles I let an LLM write early on without checking, and it has been collecting search traffic ever since. So this is a rewrite from the source, with file references you can check against the running map.
Quick context. EF-Map is a free interactive 3D map of EVE Frontier and it ships its universe as a SQLite file the browser downloads. That database holds 24,026 solar systems across 274 regions and 2,163 constellations, joined by 3,536 bidirectional stargate links. Eight carry a hidden flag, so 24,018 get drawn. That is the real scale, and it matters, because 24,000 is a big number for a web page and a modest one for a GPU. Most of what follows falls out of that gap.
One vertex per star
Every visible system is a single vertex in a single BufferGeometry, and the entire field is one THREE.Points object. This is eve-frontier-map/src/hooks/useStarfieldAndStargates.ts, around line 115:
const pointsGeometry = new THREE.BufferGeometry();
pointsGeometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
pointsGeometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
pointsGeometry.setAttribute('aSize', new THREE.Float32BufferAttribute(sizes, 1));
pointsGeometry.setAttribute('aEmissive', new THREE.Float32BufferAttribute(
new Float32Array(visibleSystemsRef.current.length).fill(1), 1));
starFieldRef.current = new THREE.Points(pointsGeometry, pointsMaterial);
sceneRef.current.add(starFieldRef.current);
Four attributes. Position, colour, a size multiplier and an emissive multiplier that starts at 1.0 and gets filled in later from real stellar temperatures once the solar system database loads. The material is a stock PointsMaterial with size: 2, sizeAttenuation: true and vertexColors: true, then customised through onBeforeCompile so the fragment shader draws the star disc analytically. That replaced a 32 pixel canvas circle with an alphaTest cutoff in July, and it stays crisp at any size with no texture fetch.
The per star appearance is less clever than people assume. There are three hoisted tints and each star picks one from a hash of its system id, then the result is multiplied by a distance falloff so the far field dims out rather than staying uniformly bright. Size variance is binary, roughly 3 percent of stars get 1.6 and everything else gets 1.0. I think the falloff curve is doing more work there than any of the colour.
So why not InstancedMesh
Going by Search Console, a decent chunk of the people who land on this page arrive on queries like three.js instancedmesh setcolorat instancecolor vertexcolors. They were being shown a fabricated code sample dressed up as production code, so here is the honest answer to the question they were actually asking.
Instancing solves draw calls. It is the right tool when you have thousands of copies of real geometry and each copy needs its own transform. A starfield is not that. Each star is a screen facing dot with no geometry to instance, and THREE.Points already draws the whole field in one call out of one buffer. Feeding it is simpler too, because you write flat Float32Array attributes instead of building a Matrix4 per star and calling setMatrixAt, and per star colour comes from vertexColors rather than setColorAt and an instanceColor buffer you have to remember to flag dirty.
The stronger version of the argument is that this scene has no draw call problem to solve. I ran a frame time census at the end of July, ablating one effect at a time against production, and it is blunt about this: every workload measured runs between 4 and 12 visible renderables. There is nothing left to batch. Move the stars onto an InstancedMesh and the draw call count barely changes while the frame time goes up, because sphere geometry rasterises a lot more pixels than a point sprite.
Where I would reach for instancing is if stars needed real geometry you could fly up to, or per instance rotation of a real mesh. For a field of dots, Points wins on everything I can measure. I would just be careful about assuming your bottleneck is draw calls before you have measured it. Mine was not.
What is actually expensive
The thing that does cost, and I had this wrong myself for months, is fill rate. The core layer is cheap. Sat on top of it are two more Points layers cloned from the same geometry, a halo per star and an angled streak per star, both additive sprites that scale up as the camera gets close. Zoom into a dense cluster and hundreds of near stars each stamp a large translucent quad over the screen, all overlapping, and the GPU shades every covered pixel. That is overdraw, and it scales with how many stars are near the camera rather than how many exist.
Both layers now ship at zero by default, and the full story of how that got measured is in A 24,000-Star Three.js Starfield Shouldn't Max Out a 4070 Super, which is the deeper follow up to this article.
Six layers over the same positions
Counting what actually gets added to the scene, there are three Points layers and three meshes. The core layer, then the glow layer which is pointsGeometry.clone() with renderOrder = 1, then the flare layer which is another clone plus a per star aRotation attribute seeded deterministically from the star index so every streak sits at its own angle, at renderOrder = 2. Same positions three times, different materials.
The backdrop is the part that is not points. Three THREE.Mesh shells built from new THREE.IcosahedronGeometry(config.sphereRadius, 4) with a custom ShaderMaterial, rendered with side: THREE.BackSide so you see them from the inside, depthWrite: false, frustumCulled = false and a negative renderOrder so they land behind everything. Near, mid and far at different radii, which is what gives the nebula backdrop its parallax when you orbit. Three spheres is a strange way to fake a skybox and it works better than I expected.
Stargates are one LineSegments
Gates are drawn as THREE.LineSegments over a BufferGeometry carrying position, color and a mid attribute holding the segment midpoint duplicated onto both vertices, which the shader uses for the fade. Two vertices per gate, all gates in one buffer, one draw call for the entire network. Same trick as the starfield with a different primitive, and it is about the only thing the old version of this page got right.
There is a nice structural fact hiding in that geometry. Only 3,033 of the 24,026 systems touch the stargate network at all. The other 20,993 have no gate whatsoever and are reachable only by burning your own fuel on a ship jump. So the line layer looks sparse next to the star layer, and that is not a rendering artefact, that is the universe.
The camera is boring on purpose
From eve-frontier-map/src/hooks/useSceneInitialization.ts, line 262:
cameraRef.current = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 10000000);
Seventy five degrees, which is the Three.js default and not a telephoto trick. Near plane 0.1, far plane ten million, because the map spans an enormous coordinate range and clipping distant stars would be worse than the depth precision cost. Pixel ratio is capped at Math.min(2, devicePixelRatio) and forced to 1.0 in Performance Mode. Tone mapping defaults to NeutralToneMapping rather than ACES, and the code comment gives the reason: star colour here is data, not a look, and ACES shifts hue.
Bloom is built lazily and skipped entirely at zero
Normal mode bloom is a chain of RenderPass, then UnrealBloomPass, then a merged output pass that fuses the tone map and sRGB encode with the vignette and dither into one draw. The chain is only built if the bloom setting is above zero, so anyone who turns it off pays nothing rather than paying for a pass that does nothing. The render target is HalfFloatType with MSAA, because values above 1.0 have to survive to the bloom threshold or hot stars stop blooming.
One thing worth stealing if you use UnrealBloomPass yourself. Three's stock version sets the blur sigma equal to the kernel radius and then truncates at that same radius, which is close to a box filter and produced visible square halos around point sources. EF-Map sets sigma to a third of the radius at the same tap count. Same cost, round halos.
Picking a star out of a point cloud
This is the part I would flag to anyone building something similar, because it is where the naive version quietly breaks. Three's Points raycast returns every point inside a world unit threshold, ordered by distance from the camera. So hits[0] is the star nearest the camera, not the star under your cursor. In a field this deep those are often different stars.
EF-Map does it in two stages. First the raycaster threshold is calibrated against camera distance on a power curve, so a zoomed out view uses a wide threshold and a close view a tight one. Then the hits get scored in screen space, in eve-frontier-map/src/hooks/usePointerHandlers.ts around line 262:
for (const hit of hits) {
const system = visibleSystemsRef.current[hit.index];
scratch.fromBufferAttribute(posAttr, hit.index);
scratch.applyMatrix4(starField.matrixWorld);
scratch.project(camera);
if (scratch.z < -1 || scratch.z > 1) continue;
const screenX = (scratch.x * 0.5 + 0.5) * rect.width;
const screenY = (-scratch.y * 0.5 + 0.5) * rect.height;
const px = Math.hypot(screenX - pointerX, screenY - pointerY);
candidates.push({ system, index: hit.index, px, camDist: hit.distance });
}
candidates.sort((a, b) => a.px - b.px);
let winner = candidates[0];
for (const c of candidates) {
if (c.px - candidates[0].px > OVERLAP_SLOP_PX) break;
if (c.camDist < winner.camDist) winner = c;
}
Project each hit to pixels, drop anything behind the camera or past the far plane, sort by pixel distance from the cursor, then among everything within 5 pixels of the best candidate prefer the one nearest the camera. That slop value is the whole trick. Without it, two stars that overlap on screen fight over the hover and flicker as you move the mouse by a pixel. If the raycast returns nothing at all there is a brute force fallback that projects every visible system and takes the closest within 28 device pixels, and at 24,000 systems that loop is cheap enough to run on a pointer event.
No GPU picking anywhere, and I have never needed it.
What it actually measures at
The census was taken on production at 1920x1080, device pixel ratio 1.0, rendering forced every animation frame with vsync off, median of three windows of three seconds, on an RTX 4070 Super. One machine and one resolution, so treat these as shape rather than as your numbers.
The default overview costs about 0.866 ms per frame. Zoomed into the dense core that goes to 1.871 ms, which is the overdraw showing up. Performance Mode with bloom on sits at 0.717 ms and barely moves when you zoom in, at 0.714 ms. The full Cinematic Effects stack is 3.311 ms. Ablating individual effects in the overview, against a noise floor of about 0.002 ms, the bloom and HDR chain is worth 0.153 ms, the parallax dust 0.070 ms, the star flare 0.056 ms and the star glow 0.037 ms. In the dense zoom the same ablations grow a lot, with the bloom target at 0.843 ms and flare at 0.592 ms.
The result that surprised me most was the CSS2D label DOM. I had assumed for months that overlaying HTML labels on a WebGL scene was a real cost, and across every workload it measured within noise, once at minus 0.001 ms. My strongest prior about this scene was wrong, which is roughly the theme of this page.
So that is what is really there. One Points cloud, two cloned sprite layers that are off by default now, three backside icospheres, one LineSegments for the gates, a default camera and a bloom chain that only exists when you ask for it. Nothing exotic, and I think the interesting decisions were mostly about what not to add. If something here contradicts the code, the code wins and I would like to know.
Related Posts
A 24,000-Star Three.js Starfield Shouldn't Max Out a 4070 Super is the follow up, on why additive sprites cost fill rate rather than draw calls.
Giving Our EVE Frontier Starfield HDR Stars with Claude Fable 5 is where the HDR target and temperature ranked bloom came from.
Fixing EF-Map's Parallax Backdrop is about the three icosphere shells behind the starfield.
Making EF-Map Faster: Three.js, WebGL, Performance Mode and FPS Caps is the wider performance toolkit.