All articles

How I built this portfolio: 42,000 points and no server

Static Astro, plain three.js and a custom shader: the decisions behind this site’s point cloud, performance and security.

I wanted a portfolio that showed the kind of problem I like to solve, not just a list of technologies. One of my projects, SiloScan, works with 3D point clouds of grain silos, so the idea arrived almost finished: the whole site would be a point cloud that changes shape as you scroll.

There are up to 42,000 points. They start as the Chapada dos Guimarães plateau, then become a scanned silo, a WhatsApp conversation, the palette of an editor theme, the layers of a clean architecture and, at the end, a globe with Cuiabá highlighted.

The stack

Layer Choice Why
Site Astro, static output HTML ready at build time, no server to maintain or attack
3D Plain three.js with custom GLSL shaders One scene, without the weight of a 3D component framework
Animation GSAP and ScrollTrigger The name’s entrance and the parallax are tied to scrolling
Scrolling Lenis Smooth scrolling, turned off for people who ask for less motion
Hosting Cloudflare Workers with static assets A deploy on every push, HTTPS and edge caching

I didn’t use a UI framework. The copy for both languages lives in a single TypeScript file, and Astro builds one page per language.

Six shapes, one buffer

The most interesting part is that the GPU does almost everything. Each point carries all six possible positions as attributes (p0 to p5), computed once at load time. On every frame, JavaScript only says which shape the cloud is moving from, which shape it’s moving to, and how far along it is. The vertex shader does the blending:

// each point leaves at a different moment, so the shape dissolves in a wave
float tt = smoothstep(0.0, 1.0, clamp((uMix - aRnd * 0.35) / 0.65, 0.0, 1.0));
vec3 pos = mix(pick(uFrom), pick(uTo), tt);

// halfway through, the points scatter and then gather again
vec3 dir = normalize(vec3(sin(aRnd * 91.7), cos(aRnd * 47.3), sin(aRnd * 13.1 + 1.0)));
pos += dir * sin(tt * 3.14159) * (0.5 + aRnd * 0.9);

aRnd is a fixed random number per point. It makes the points leave at different times, which is why the transition looks like a wave rather than a block sliding around.

On top of that, a few details respond to whoever is browsing:

  • The cursor pushes points away, like a hand passing over sand, and the points closest to it grow and light up.
  • When a shape finishes assembling, a band of light sweeps down through it, like a scanner reading.
  • Fine dust crosses the whole page and stretches with scroll speed.
  • In the Serena theme section, the theme’s real palettes recolor the cloud.

The glow comes from the fragment shader itself, with a sharp core and a soft halo on each point. I didn’t need post-processing, which would cost an extra full-screen render pass.

Performance: the cloud can’t hold up the text

A heavy WebGL site looks great on my computer and freezes on someone else’s. So I treated the 3D as decoration that arrives later:

  • three.js lives in a separate file and only downloads when the browser is idle. The name and the copy show up first.
  • Before downloading anything, the site checks whether WebGL runs on the graphics card. On machines without a GPU the browser draws 3D on the CPU, and the whole page freezes. In those cases the site uses a static background and never downloads the library.
  • If frames get slow even with a GPU, the scene lowers its resolution and, if it still needs to, drops half the points.
  • The scene stops when the tab is hidden, and the heading and body fonts are preloaded.

On Google’s PageSpeed Insights, it scores 97 for performance on mobile and 100 on desktop, with 100 for accessibility, best practices and SEO on both.

Security on a static site

A static site has no database or server to break into, but it can still be used to inject scripts. The site sends a strict Content Security Policy, with no unsafe-inline. An excerpt:

default-src 'self'; script-src 'self' https://static.cloudflareinsights.com;
style-src 'self'; object-src 'none'; frame-ancestors 'none'

For that to work, all CSS and JavaScript must live in external files. I set Astro to never inline styles into the HTML, and I swapped the Shiki code highlighter, which uses inline styles, for Prism, which uses classes. The code blocks in this article are colored with the Serena Dark palette.

The site also sends HSTS, nosniff, Referrer-Policy, Permissions-Policy and immutable caching for hashed files.

Accessibility

  • People who set their system to reduce motion get the page without smooth scrolling, without parallax and with instant transitions.
  • The name at the top animates letter by letter, but screen readers read the full name at once.
  • The command palette (⌘K or Ctrl+K) uses the native dialog element and works with the keyboard alone. On phones, it becomes the menu.
  • Without WebGL, no content disappears: the cloud is decoration, the text is the site.

A bug worth the article

During testing, the cloud sometimes drifted off screen. The numbers showed the horizontal offset going to -14, then -126. The cause: under some conditions, the time between two frames came out negative. The smoothing I used, lerp(target, 1 - exp(-dt * 3)), stops smoothing with a negative dt and starts pushing the value away from the target on every frame.

The fix was one line: clamp the interval between zero and a ceiling.

const dt = Math.min(Math.max((now - last) / 1000, 0), 0.05);

It’s the kind of bug that only shows up on slow machines, which is why I test on slow machines.

What I’d bring to a client project

  • Start with the content. The effect only works because each shape tells the story of a real project.
  • Treat 3D and animation as something that adapts to the device, not a requirement. The site has to work with them turned off.
  • Measure. Detecting software WebGL took the mobile performance score from 58 to over 90.

If you’d like to talk about a project or any of these decisions, my email is joao@joaoangello.dev.