Are you bored of the infinite scrolling of social media (twitter, tik-tok, etc)? Then have fun scrolling in infinite space :)
xxxxxxxxxx
/*
* An Infinitely Pannable and Zoomable Starfield
*
* Inspired by Miles Shang
* https://mshang.ca/2014/10/20/starfield.html
*
* Are you bored of the infinite scrolling of social media (twitter, tik-tok, etc)?
* Then have fun scrolling in infinite space :)
* This version by Juan Carlos Ponce Campuzano
* 03/04/2025
*
*/
// Main sketch
const STAR_SIZE_PIXELS = 3;
const DECELERATION_BLEED_RATIO_PER_TICK = 0.05;
let worldOffsetX = 0;
let worldOffsetY = 0;
let scale = 1;
let lastWorldOffsetX = null;
let lastWorldOffsetY = null;
let lastScale = null;
let mousedown = false;
let lastMouseX;
let lastMouseY;
let pixelVelocityX = 0;
let pixelVelocityY = 0;
let lastRenderTimestamp = 0;
function setup() {
createCanvas(windowWidth, windowHeight);
background(0);
// Touch events for mobile
const hammer = new Hammer.Manager(document.getElementById('defaultCanvas0'));
hammer.add(new Hammer.Pan());
hammer.add(new Hammer.Pinch());
hammer.on('panstart', (e) => {
mousedown = true;
lastMouseX = 0;
lastMouseY = 0;
});
hammer.on('panmove', (e) => {
worldOffsetX -= (e.deltaX - lastMouseX) / scale;
worldOffsetY -= (e.deltaY - lastMouseY) / scale;
lastMouseX = e.deltaX;
lastMouseY = e.deltaY;
});
hammer.on('panend', (e) => {
mousedown = false;
pixelVelocityX = -e.velocityX;
pixelVelocityY = -e.velocityY;
});
hammer.on('pinchstart', (e) => {
lastScale = 1;
pixelVelocityX = 0;
pixelVelocityY = 0;
});
hammer.on('pinchmove', (e) => {
const scaleDelta = e.scale / lastScale;
worldOffsetX += (e.center.x - canvas.offsetLeft) * (1 - 1 / scaleDelta) / scale;
worldOffsetY += (e.center.y - canvas.offsetTop) * (1 - 1 / scaleDelta) / scale;
scale *= scaleDelta;
lastScale = e.scale;
});
}
function draw() {
const timestamp = millis();
// Deceleration
if (lastRenderTimestamp && !mousedown) {
const dt = timestamp - lastRenderTimestamp;
if (pixelVelocityX || pixelVelocityY) {
worldOffsetX -= pixelVelocityX * dt / scale;
worldOffsetY -= pixelVelocityY * dt / scale;
}
const ratioDeceleration = 1 - Math.min(1, DECELERATION_BLEED_RATIO_PER_TICK * dt / (1000 / 60));
pixelVelocityX *= ratioDeceleration;
pixelVelocityY *= ratioDeceleration;
}
// Prevent redrawing when velocity is very small
if (Math.abs(pixelVelocityX) < 0.01 && Math.abs(pixelVelocityY) < 0.01) {
pixelVelocityX = 0;
pixelVelocityY = 0;
}
lastRenderTimestamp = timestamp;
// Skip redraw if nothing changed
if (
worldOffsetX === lastWorldOffsetX &&
worldOffsetY === lastWorldOffsetY &&
scale === lastScale
) {
return;
}
lastWorldOffsetX = worldOffsetX;
lastWorldOffsetY = worldOffsetY;
lastScale = scale;
// Get stars for current view
const stars = getStars(
worldOffsetX,
worldOffsetY,
width / scale,
height / scale
);
// Batch stars by brightness for efficient rendering
const brightnessToStars = Array(101).fill().map(() => []);
for (const star of stars) {
brightnessToStars[Math.floor(star.brightness * 100)].push(star);
}
background(0);
noStroke();
// Draw stars
for (let i = 0; i <= 100; i++) {
for (const star of brightnessToStars[i]) {
// Use the star's hash to determine color (for consistency)
const colorSeed = hashFnv32a(star.worldX + ':' + star.worldY) % 5;
const alpha = i / 100 * 255;
switch(colorSeed) {
case 0: // White
fill(255, 255, 255, alpha);
break;
case 1: // Yellow
fill(255, 255, 100, alpha);
break;
case 2: // Blue
fill(100, 100, 255, alpha);
break;
case 3: // Purple
fill(200, 100, 255, alpha);
break;
case 4: // Green
fill(100, 255, 100, alpha);
break;
}
rect(
(star.worldX - worldOffsetX) * scale,
(star.worldY - worldOffsetY) * scale,
STAR_SIZE_PIXELS,
STAR_SIZE_PIXELS
);
}
}
}
function mousePressed() {
mousedown = true;
lastMouseX = mouseX;
lastMouseY = mouseY;
pixelVelocityX = 0;
pixelVelocityY = 0;
return false;
}
function mouseDragged() {
worldOffsetX -= (mouseX - lastMouseX) / scale;
worldOffsetY -= (mouseY - lastMouseY) / scale;
lastMouseX = mouseX;
lastMouseY = mouseY;
return false;
}
function mouseReleased() {
mousedown = false;
return false;
}
function mouseWheel(event) {
const mult = event.delta > 0 ? 1.1 : 1 / 1.1;
worldOffsetX += mouseX * (1 - 1 / mult) / scale;
worldOffsetY += mouseY * (1 - 1 / mult) / scale;
scale *= mult;
pixelVelocityX = 0;
pixelVelocityY = 0;
return false;
}
function windowResized() {
resizeCanvas(windowWidth, windowHeight);
}
// Star class
class Star {
constructor(worldX, worldY, brightness) {
this.worldX = worldX;
this.worldY = worldY;
this.brightness = brightness;
}
}
// Hash function
function hashFnv32a(str) {
let hval = 0x811c9dc5;
for (let i = 0, l = str.length; i < l; i++) {
hval ^= str.charCodeAt(i);
hval += (hval << 1) + (hval << 4) + (hval << 7) + (hval << 8) + (hval << 24);
}
return hval >>> 0;
}
// Simple cache implementation
/*
class SimpleCache {
constructor(maxSize) {
this.maxSize = maxSize;
this.cache = new Map();
this.keys = [];
}
get(key) {
return this.cache.get(key);
}
set(key, value) {
if (this.cache.size >= this.maxSize) {
const oldestKey = this.keys.shift();
this.cache.delete(oldestKey);
}
this.cache.set(key, value);
this.keys.push(key);
}
}
*/
// Random Replacement Cache
class RRCache {
constructor(capacity) {
this.capacity = capacity;
this.list = [];
this.map = {};
}
get(key) {
return this.map[key];
}
set(key, value) {
if (this.list.length >= this.capacity) {
const index = Math.floor(Math.random() * this.capacity);
delete this.map[this.list[index]];
this.list[index] = key;
} else {
this.list.push(key);
}
this.map[key] = value;
}
}
// Star generator
const BRIGHTNESS_FACTOR = 50;
const STAR_RANGE_INDICES = 10;
const LEVEL_DEPTH = 5;
const MAX_INT = -1 >>> 1;
//const cache = new SimpleCache(100000);
const cache = new RRCache(100000);
function cachedHash(to_hash) {
const cached = cache.get(to_hash);
if (cached) return cached;
const digest = [
hashFnv32a(to_hash + 'a'),
hashFnv32a(to_hash + 'b'),
hashFnv32a(to_hash + 'c')
];
cache.set(to_hash, digest);
return digest;
}
function getStars(worldOffsetX, worldOffsetY, worldWidth, worldHeight) {
const levelForCurrentScale = -Math.log(worldWidth * worldHeight) / 2;
const startLevel = Math.floor(levelForCurrentScale);
const stars = [];
for (let level = startLevel; level < startLevel + LEVEL_DEPTH; level++) {
const spacing = Math.exp(-level);
for (
let xIndex = Math.floor(worldOffsetX / spacing) - STAR_RANGE_INDICES;
xIndex <= Math.ceil((worldOffsetX + worldWidth) / spacing) + STAR_RANGE_INDICES;
xIndex++
) {
for (
let yIndex = Math.floor(worldOffsetY / spacing) - STAR_RANGE_INDICES;
yIndex <= Math.ceil((worldOffsetY + worldHeight) / spacing) + STAR_RANGE_INDICES;
yIndex++
) {
const hash = cachedHash(xIndex + ':' + yIndex + ':' + level);
stars.push(new Star(
xIndex * spacing + (hash[0] / MAX_INT) * spacing * STAR_RANGE_INDICES,
yIndex * spacing + (hash[1] / MAX_INT) * spacing * STAR_RANGE_INDICES,
Math.max(
0,
Math.atan(
(
Math.exp(
levelForCurrentScale - level - Math.abs(hash[2] / MAX_INT)
) - Math.exp(
levelForCurrentScale - (startLevel + LEVEL_DEPTH)
)
) * BRIGHTNESS_FACTOR
) * 2 / PI
)
));
}
}
}
return stars;
}