The globe draws the poles as they are, and nothing while idle
The globe is a UV sphere: towards the poles its triangles narrow to slivers, and texture coordinates interpolated across them pinched the map into a star around each pole. Each pixel now computes its own coordinates from its exact point on the sphere, with seam-safe mipmap gradients; the map wraps horizontally and gets 8x anisotropic filtering. That showed a flaw in the day texture: NASA's mosaic has a lighter strip at its east edge near the poles, a line from each pole once drawn exactly. The day texture is now built from NASA's original Blue Marble (December 2004) by tools/globe/prepare-day-texture.py, which blends that strip into the map where both sides of the seam agree; same 4096x2048 size, provenance and hashes in SOURCES.md. globe.gl redrew 60 times a second even when nothing moved; it now draws only during camera flights, hover pulses, auto-rotation and the reader's drags, so an idle globe costs no GPU time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
620cbef219
commit
69d0a641fe
8 files changed
+205
-14
No files matched your search
+113
-7
@@ -4,8 +4,9 @@
|
||||
// Hovering a post pulses a ring where it is and draws an arc to it from PrivaPub's server; leaving it goes back to the
|
||||
// view. Posts of servers behind a CDN have no place: they are grouped per CDN in labels on a ring above the Arctic,
|
||||
// which says "CDN" rather than any place; hovering one draws its arc to that group and a ring where the server was
|
||||
// before the CDN, when PrivaPub saw it there. Day texture with the light theme, night with the dark one; motion follows prefers-reduced-motion, and nothing
|
||||
// renders while the page or the globe is hidden. No .NET round-trip on scroll or hover: the posts' places come in one
|
||||
// before the CDN, when PrivaPub saw it there. Day texture with the light theme, night with the dark one; motion follows prefers-reduced-motion. It
|
||||
// renders only while something moves (a camera flight, a hovered post's pulse, auto-rotation, the reader dragging or
|
||||
// zooming) and never while the page or the globe is hidden: an idle globe costs no GPU time. No .NET round-trip on scroll or hover: the posts' places come in one
|
||||
// call per feed change (setPosts), the cards are matched by data-post-id.
|
||||
|
||||
const vendor = new URL("../vendor/globe/", import.meta.url).href
|
||||
@@ -47,6 +48,10 @@ export function create(element, scrollContainerId) {
|
||||
renderer: null,
|
||||
texture: null,
|
||||
paused: false,
|
||||
idle: false,
|
||||
running: true,
|
||||
interacting: false,
|
||||
awakeUntil: 0,
|
||||
observers: [],
|
||||
listeners: [],
|
||||
timers: [],
|
||||
@@ -71,6 +76,7 @@ export function setPosts(posts, self) {
|
||||
state.self = self && Number.isFinite(self.lat) && Number.isFinite(self.lng) ? self : null
|
||||
drawPoints()
|
||||
scheduleView()
|
||||
wake()
|
||||
}
|
||||
|
||||
// Live numbers for the nerd stats overlay; null until the globe exists.
|
||||
@@ -95,8 +101,10 @@ export function stats() {
|
||||
fps: state.fps,
|
||||
renderer: state.renderer,
|
||||
texture: state.texture,
|
||||
anisotropy: state.anisotropy,
|
||||
autoRotate: !!state.globe.controls().autoRotate,
|
||||
paused: state.paused,
|
||||
idle: state.idle,
|
||||
hovered: state.hovered && state.points.has(state.hovered) ? state.points.get(state.hovered).label ?? state.hovered : null,
|
||||
arcs: state.globe.arcsData().length,
|
||||
rings: state.globe.ringsData().length
|
||||
@@ -129,6 +137,7 @@ function resize() {
|
||||
}
|
||||
state.globe.width(width).height(height)
|
||||
pause(document.visibilityState !== "visible")
|
||||
wake()
|
||||
}
|
||||
|
||||
async function build(width, height) {
|
||||
@@ -167,9 +176,19 @@ async function build(width, height) {
|
||||
.htmlLng(g => g.lng)
|
||||
.htmlAltitude(0.12)
|
||||
.htmlElement(cdnLabel)
|
||||
sharpen(globe)
|
||||
const controls = globe.controls()
|
||||
controls.autoRotateSpeed = 0.35
|
||||
controls.enableDamping = true
|
||||
controls.addEventListener("start", () => {
|
||||
state.interacting = true
|
||||
wake()
|
||||
})
|
||||
controls.addEventListener("end", () => {
|
||||
state.interacting = false
|
||||
wake(1500)
|
||||
})
|
||||
listen(state.element, "pointermove", () => wake(1000))
|
||||
globe.pointOfView({ lat: 30, lng: 10, altitude: 2.6 * narrowness() }, 0)
|
||||
applyTheme()
|
||||
readRenderer()
|
||||
@@ -187,6 +206,57 @@ async function build(width, height) {
|
||||
}
|
||||
}
|
||||
|
||||
// The globe is a UV sphere: towards the poles its triangles narrow to slivers meeting at one point, and texture
|
||||
// coordinates interpolated across them pinch the map into a star around the axis. Each pixel computes its own instead,
|
||||
// from its exact point on the sphere (the inverse of three.js SphereGeometry's mapping), so the map is drawn the same
|
||||
// everywhere. The mipmap level comes from gradients taken on whichever of two longitude ranges has no jump at that pixel,
|
||||
// and the map wraps around horizontally (keepSharp), so the 180° meridian shows no seam; anisotropic filtering keeps
|
||||
// high latitudes sharp at grazing angles, at 8× at most: sharper than that is barely visible and costs on phones.
|
||||
function sharpen(globe) {
|
||||
const material = globe.globeMaterial()
|
||||
material.onBeforeCompile = shader => {
|
||||
shader.vertexShader = shader.vertexShader
|
||||
.replace("#include <common>", "#include <common>\nvarying vec3 vSpherePoint;")
|
||||
.replace("#include <begin_vertex>", "#include <begin_vertex>\nvSpherePoint = position;")
|
||||
shader.fragmentShader = shader.fragmentShader
|
||||
.replace("#include <common>", "#include <common>\nvarying vec3 vSpherePoint;")
|
||||
.replace("#include <map_fragment>", `
|
||||
#ifdef USE_MAP
|
||||
vec3 spherePoint = normalize(vSpherePoint);
|
||||
float sphereU = atan(spherePoint.z, -spherePoint.x) / (2.0 * PI);
|
||||
float sphereV = 1.0 - acos(clamp(spherePoint.y, -1.0, 1.0)) / PI;
|
||||
float uWhole = fract(sphereU);
|
||||
float uShifted = fract(sphereU + 0.5) - 0.5;
|
||||
vec2 gradientX = vec2(dFdx(uWhole), dFdx(sphereV));
|
||||
vec2 gradientY = vec2(dFdy(uWhole), dFdy(sphereV));
|
||||
float shiftedX = dFdx(uShifted);
|
||||
float shiftedY = dFdy(uShifted);
|
||||
if (abs(shiftedX) + abs(shiftedY) < abs(gradientX.x) + abs(gradientY.x)) {
|
||||
gradientX.x = shiftedX;
|
||||
gradientY.x = shiftedY;
|
||||
}
|
||||
vec4 sampledDiffuseColor = textureGrad(map, vec2(uWhole, sphereV), gradientX, gradientY);
|
||||
diffuseColor *= sampledDiffuseColor;
|
||||
#endif`)
|
||||
}
|
||||
material.customProgramCacheKey = () => "privapub-per-pixel-sphere"
|
||||
material.needsUpdate = true
|
||||
state.anisotropy = Math.min(8, globe.renderer().capabilities.getMaxAnisotropy())
|
||||
}
|
||||
|
||||
// Every texture the globe loads (a theme change loads the other one) wraps around horizontally, so filtering at the
|
||||
// 180° meridian blends the map's two edges instead of clamping each, and gets anisotropic filtering.
|
||||
const RepeatWrapping = 1000//three.js's RepeatWrapping: the bundle does not expose THREE
|
||||
function keepSharp() {
|
||||
const map = state?.globe?.globeMaterial().map
|
||||
if (map && (map.anisotropy !== state.anisotropy || map.wrapS !== RepeatWrapping)) {
|
||||
map.anisotropy = state.anisotropy
|
||||
map.wrapS = RepeatWrapping
|
||||
map.needsUpdate = true
|
||||
wake(500)
|
||||
}
|
||||
}
|
||||
|
||||
// Which cards are in view: an IntersectionObserver on every [data-post-id] of the scroll container, re-attached as
|
||||
// the list changes; hover by delegation on the container.
|
||||
function watchCards() {
|
||||
@@ -257,10 +327,40 @@ function pause(paused) {
|
||||
if (!state?.globe || state.paused === paused)
|
||||
return
|
||||
state.paused = paused
|
||||
if (paused)
|
||||
state.globe.pauseAnimation()
|
||||
else
|
||||
render()
|
||||
}
|
||||
|
||||
// globe.gl draws every frame; this keeps its loop running only while the globe is shown and something moves.
|
||||
function render() {
|
||||
const run = !state.paused && !state.idle
|
||||
if (run === state.running)
|
||||
return
|
||||
state.running = run
|
||||
if (run)
|
||||
state.globe.resumeAnimation()
|
||||
else
|
||||
state.globe.pauseAnimation()
|
||||
}
|
||||
|
||||
// Something is about to move: draw for at least ms more.
|
||||
function wake(ms = 1500) {
|
||||
if (!state?.globe)
|
||||
return
|
||||
state.awakeUntil = Math.max(state.awakeUntil, performance.now() + ms)
|
||||
if (state.idle) {
|
||||
state.idle = false
|
||||
render()
|
||||
}
|
||||
}
|
||||
|
||||
// Checked every second: nothing hovered, dragged, rotating or flying, so the last frame can stay on screen.
|
||||
function settle() {
|
||||
if (!state?.globe || state.idle)
|
||||
return
|
||||
if (state.hovered || state.interacting || state.globe.controls().autoRotate || performance.now() < state.awakeUntil)
|
||||
return
|
||||
state.idle = true
|
||||
render()
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
@@ -275,6 +375,7 @@ function applyTheme() {
|
||||
state.colours = null
|
||||
state.globe.atmosphereColor(colour("server", 1))
|
||||
drawPoints()
|
||||
wake(2000)
|
||||
}
|
||||
|
||||
// The theme's colours, resolved to rgb (the tokens are hsl() of CSS variables, which three.js cannot read).
|
||||
@@ -392,6 +493,7 @@ function hover(key) {
|
||||
if (!state?.globe || state.hovered === key)
|
||||
return
|
||||
state.hovered = key
|
||||
wake(1300)
|
||||
const post = key && state.points.get(key)
|
||||
drawRings()
|
||||
if (!post) {
|
||||
@@ -434,6 +536,7 @@ function updateView() {
|
||||
state.view = null
|
||||
state.spreadKm = 0
|
||||
controls.autoRotate = !reducedMotion.matches
|
||||
wake()
|
||||
return
|
||||
}
|
||||
controls.autoRotate = false
|
||||
@@ -458,6 +561,7 @@ function updateView() {
|
||||
function fly(view, ms) {
|
||||
if (!view || !state?.globe)
|
||||
return
|
||||
wake((reducedMotion.matches ? 0 : ms) + 400)
|
||||
state.globe.pointOfView(view, reducedMotion.matches ? 0 : ms)
|
||||
}
|
||||
|
||||
@@ -491,14 +595,16 @@ function countFrames() {
|
||||
const tick = () => {
|
||||
if (state !== current)
|
||||
return
|
||||
if (!state.paused)
|
||||
if (state.running)
|
||||
state.frames++
|
||||
keepSharp()
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
state.timers.push(setInterval(() => {
|
||||
state.fps = state.paused ? 0 : state.frames
|
||||
state.fps = state.running ? state.frames : 0
|
||||
state.frames = 0
|
||||
settle()
|
||||
}, 1000))
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user