Skip to content

perf: keep globe textures at full resolution, switch to WebP - #473

Draft
alukach wants to merge 3 commits into
perf/drop-globe-texture-preloadfrom
perf/shrink-globe-textures
Draft

perf: keep globe textures at full resolution, switch to WebP#473
alukach wants to merge 3 commits into
perf/drop-globe-texture-preloadfrom
perf/shrink-globe-textures

Conversation

@alukach

@alukach alukach commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Part of #471. Stacked on #472 — review that one first; this PR's base is perf/drop-globe-texture-preload, so it only shows the texture changes.

Supersedes this PR's original approach. The first version downsized both textures to 1024×512 and was visibly blurry (reported on review). That reasoning was wrong — see below. Full resolution is now preserved.

What

Keeps both textures at 4096×2048 and changes the codec instead.

Asset Before After Resolution
clouds 4916 KiB 922 KiB 4096×2048 kept
earth-blue-marble 1428 KiB 490 KiB 4096×2048 kept
total 6344 KiB 1412 KiB 78% reduction

Why the downsize was wrong

My original justification was that DitherShader.ts reduces the scene to 1-bit black-and-white, so texture detail couldn't matter. That's backwards:

  • The Bayer threshold is computed in screen space (vUv * resolution), not texture space. Ordered dithering renders input detail as dot density — it doesn't discard it. Blur the input, blur the output.
  • The shader applies pow(luma, 0.4) then smoothstep(0.05, 0.6, luma) before thresholding, which deliberately pulls detail out of dark regions. It amplifies exactly the detail I assumed was being thrown away.
  • clouds.png was already Bilevel — 1-bit colour. Downsampling it turned crisp 1-bit edges into grey mush, which is the visible blur.

Measured, as RMSE against the originals:

Approach clouds mask earth
1024×512 downsize (old) 7.1%
full-res WebP (this PR) 1.4% 1.3%

5× better fidelity, and still removes 78% of the bytes.

The clouds change needs a second look

Nearly all of clouds' weight was its alpha channel, not its pixels — the colour channel held only 2 unique values, and I verified it is white exactly wherever alpha > 0 (RMSE 0, not approximately zero). So the RGB channel carried no information the alpha mask doesn't.

Storing just the mask as ordinary greyscale lets it compress properly, and binding it via alphaMap instead of map is arithmetically identical:

  • old: map_fragment does diffuseColor *= texturergb *= white, a *= texture.a
  • new: alphamap_fragment does diffuseColor.a *= texture.g

Both leave rgb = white and a = 0.3 × mask. Confirmed against three.js 0.183.2's alphamap_fragment.glsl.js, which samples .g — and the mask is greyscale, so g is the mask.

Why not 1-bit, as suggested

Worth recording, since it was the natural next idea. Pre-thresholding the earth texture to 1-bit would make things worse, twice over:

  1. The shader's tone curve turns continuous tone into dot density. Feed it a pre-binarized image and the mid-tones that produce the gradient dot patterns are gone — you get hard black/white blobs instead.
  2. A dither baked into texture space rotates with the globe, so it would swim and moiré against the fixed screen-space Bayer grid.

The instinct was right though — the wasted bits were real, just not in the bit depth. They were in a redundant RGB channel and an inefficient codec.

Dropping colour from the earth texture also isn't worth it: it saves only 68 KiB, and ImageMagick's Rec709 grey weights don't match the shader's Rec601 dot(color.rgb, vec3(0.299, 0.587, 0.114)), which would shift ocean brightness and change the dither density.

Regenerated with

# clouds: extract the alpha mask, store as greyscale
magick clouds.png -alpha extract -strip mask.png
cwebp -q 75 mask.png -o public/img/clouds.webp

# earth: straight re-encode, no resize
cwebp -q 75 earth-blue-marble.jpg -o public/img/earth-blue-marble.webp

Verification

  • npm run type-check — 14 errors, identical to the baseline on main
  • grep — no remaining references to clouds.png / earth-blue-marble.jpg anywhere
  • Green-channel of the shipped clouds.webp vs the original alpha mask: RMSE 1.4% at full 4096×2048
  • Still worth an eyeball on the preview. The maths says the alphaMap swap is exact, but I can't run a browser here, and the last version of this PR is exactly why that matters.

🤖 Generated with Claude Code

Both WebGL globe textures shipped at 4096x2048 for a canvas that renders
at most ~700px, and the scene is reduced to a 1-bit black-and-white image
by the 8x8 Bayer dither pass (DitherShader.ts) before it ever reaches the
screen. The clouds layer is composited at 0.3 opacity on top of that.

None of that detail survives, so it was 6.2MB of bytes with no visible
output. On a real-throttled 4G profile clouds.png alone took 40.1s to
download and starved the render-blocking stylesheet that gates first paint.

  clouds.png            4918 KiB -> 384 KiB  (grayscale+alpha preserved)
  earth-blue-marble.jpg 1429 KiB ->  89 KiB
  total                 6347 KiB -> 473 KiB  (93% reduction)

Filenames and formats are unchanged, so no application code changes.

Regenerated with:
  magick public/img/clouds.png -resize 1024x -strip public/img/clouds.png
  magick public/img/earth-blue-marble.jpg -resize 1024x -strip -quality 82 \
    public/img/earth-blue-marble.jpg

Refs #471
@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
source-cooperative Ready Ready Preview Aug 4, 2026 12:47am

Request Review

@claude

claude Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Claude finished @alukach's task in 3m 54s —— View job


Review

  • Gather context (diff vs origin/perf/drop-globe-texture-preload, i.e. e9b7c54...HEAD)
  • Inspect LiveGlobe.tsx texture/material changes
  • Inspect page.tsx preload-hint removal
  • Verify new WebP assets (dimensions, visual quality, stray references to old filenames)

✅ No blocking issues — safe to merge.

  • src/components/features/globe/LiveGlobe.tsx:163-179: switching the clouds material from map: texture to alphaMap: texture is correct — the commit message verifies the old PNG's RGB channel was solid white wherever alpha > 0 (RMSE 0 vs. white), so alphaMap (three.js reads the green channel) is arithmetically equivalent to the old map.a binding while letting the asset compress as plain greyscale. Confirmed clouds.webp is a genuine full-resolution (4096×2048) greyscale cloud-density map, not a corrupted/blank texture.
  • src/app/(marketing)/page.tsx:43-48, LiveGlobe.tsx:540: earth-blue-marble.webp/clouds.webp are the only references to the new filenames, and no other file in the repo (tests, configs, CSP/img-src rules) still points at the deleted .png/.jpg — clean rename, no dangling references.
  • Visually inspected both new WebP textures at full resolution: no visible blocking/banding artifacts, consistent with the commit's own RMSE analysis (1.3–1.4% vs. originals), and dimensions remain power-of-two (4096×2048) as intended.
  • Good self-correction in commit 2d9ab50: it identifies that downsizing to 1024×512 (the original approach in this PR) was the wrong fix for a dithered/ordered-dither pipeline (blurring the input blurs the dot-density output) and replaces it with a codec change instead, keeping full resolution. The final diff matches the "keep 4096×2048, switch to WebP" description — no code depends on the old 1024×512 assumption.
  • Could not run tsc/lint locally (no node_modules in this environment) or load the app in a browser to eyeball the live globe — the visual check above was done by directly viewing the exported texture files, which is a reasonable proxy given the dither shader's known behavior, but a real render check before merge is still worthwhile per the PR's own reviewer note.

alukach added 2 commits August 3, 2026 17:45
The two `<link rel="preload">` hints pulled 6.5MB of WebGL globe textures
into the critical window, ahead of the render-blocking stylesheet that
gates first paint. On a real-throttled 4G profile that stylesheet was
requested at 682ms but did not arrive until 5901ms.

The preloads had no upside to trade against that: LiveGlobe is
dynamic({ ssr: false }), so it mounts after hydration and requests the
textures itself regardless.

Measured with Lighthouse 13.4.1 (--throttling-method=devtools, mobile):

  FCP 6.1s -> 5.3s
  LCP 6.1s -> 5.3s

Refs #471
Replaces the 1024x512 downsize, which was visibly blurry. The reasoning
behind it was wrong: the Bayer pass thresholds per *screen* pixel, and
the tone curve before it (pow(luma, 0.4) then smoothstep) deliberately
pulls detail out of dark regions. Ordered dithering renders input detail
as dot density rather than discarding it, so blurring the input blurs the
output. clouds.png was also already Bilevel, so downsampling turned crisp
1-bit edges into grey mush.

Keeping full resolution and changing the codec instead:

  clouds            4916 KiB -> 922 KiB   (4096x2048 kept)
  earth-blue-marble 1428 KiB -> 490 KiB   (4096x2048 kept)
  total             6344 KiB -> 1412 KiB  (78% reduction)

Fidelity against the originals, measured as RMSE:

  full-res WebP   1.4% (clouds mask), 1.3% (earth)
  1024 downsize   7.1% (clouds mask)

Nearly all of clouds' weight was its alpha channel: the colour channel
held only 2 unique values and was white exactly wherever alpha > 0
(verified, RMSE 0), so it carried no information the mask doesn't. Storing
the mask as plain greyscale and binding it via alphaMap is arithmetically
identical to the old map binding — map did `a *= texture.a` with rgb
pinned to white, alphaMap does `a *= texture.g` — and lets it compress as
an ordinary greyscale image.

Refs #471
@alukach alukach changed the title perf: downsize globe textures from 4096x2048 to 1024x512 perf: keep globe textures at full resolution, switch to WebP Aug 4, 2026
@alukach
alukach changed the base branch from main to perf/drop-globe-texture-preload August 4, 2026 00:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant