diff --git a/.firebaserc b/.firebaserc deleted file mode 100644 index d585736..0000000 --- a/.firebaserc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "projects": { - "default": "schmelczer-dev" - } -} diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 22aa2b5..3d73601 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -28,10 +28,7 @@ jobs: run: npm ci - name: Build - # webpack 4 needs the legacy OpenSSL provider on Node 17+ run: npm run build - env: - NODE_OPTIONS: --openssl-legacy-provider - name: Copy build to host pages mount (sdf2d) if: github.event_name == 'push' && github.ref == 'refs/heads/main' diff --git a/firebase.json b/firebase.json deleted file mode 100644 index 5917d4b..0000000 --- a/firebase.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "hosting": { - "public": "dist", - "ignore": [ - "firebase.json", - "**/.*", - "**/node_modules/**" - ], - "site": "sdf2d-schmelczer-dev", - "rewrites": [ - { - "source": "**", - "destination": "/index.html" - } - ] - } -} diff --git a/package-lock.json b/package-lock.json index 5692bb2..a8ecde4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "resolve-url-loader": "^5.0.0", "sass": "^1.100.0", "sass-loader": "^17.0.0", - "sdf-2d": "^0.7.6", + "sdf-2d": "^0.8.0", "source-map-loader": "^5.0.0", "ts-loader": "^9.6.0", "typescript": "^6.0.3", @@ -39,6 +39,34 @@ "webpack-dev-server": "^5.2.4" } }, + "../sdf-2d": { + "version": "0.8.0", + "dev": true, + "license": "ISC", + "dependencies": { + "gl-matrix": "^3.4.4", + "resize-observer-polyfill": "^1.5.1" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@typescript-eslint/eslint-plugin": "^8.60.1", + "@typescript-eslint/parser": "^8.60.1", + "eslint": "^10.4.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", + "eslint-plugin-unused-imports": "^4.4.1", + "globals": "^17.6.0", + "prettier": "^3.8.3", + "raw-loader": "^4.0.2", + "terser-webpack-plugin": "^5.6.1", + "ts-loader": "^9.6.0", + "typedoc": "^0.28.19", + "typedoc-plugin-extras": "^4.0.1", + "typescript": "^6.0.3", + "webpack": "^5.107.2", + "webpack-cli": "^7.0.3" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -6666,13 +6694,6 @@ "dev": true, "license": "MIT" }, - "node_modules/resize-observer-polyfill": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", - "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", - "dev": true, - "license": "MIT" - }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -6919,15 +6940,8 @@ "license": "MIT" }, "node_modules/sdf-2d": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/sdf-2d/-/sdf-2d-0.7.6.tgz", - "integrity": "sha512-aBJUjwYjWP+/fSvLHH6vhpFyWeSJNCSEHpE0XBX69s2bQlo/NOUr3nq/KuhRZ6A3FeXrGU4lzyurHJ1N8f4rFg==", - "dev": true, - "license": "ISC", - "dependencies": { - "gl-matrix": "^3.3.0", - "resize-observer-polyfill": "^1.5.1" - } + "resolved": "../sdf-2d", + "link": true }, "node_modules/select-hose": { "version": "2.0.0", diff --git a/package.json b/package.json index 2a448de..04a34f1 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "resolve-url-loader": "^5.0.0", "sass": "^1.100.0", "sass-loader": "^17.0.0", - "sdf-2d": "^0.7.6", + "sdf-2d": "^0.8.0", "source-map-loader": "^5.0.0", "ts-loader": "^9.6.0", "typescript": "^6.0.3", diff --git a/src/helper/handle-insights.ts b/src/helper/handle-insights.ts index 6dfe73a..6c5f85d 100644 --- a/src/helper/handle-insights.ts +++ b/src/helper/handle-insights.ts @@ -1,31 +1,22 @@ -const baseUri = 'https://insights.decla.red'; -const type = 'sdf-2d-demo'; +import { track } from '../analytics'; export const handleInsights = async (initialData: any): Promise<(data: any) => void> => { - try { - const sessionId = await createSession(initialData); - return (data) => createFrame(sessionId, data); - } catch { - return () => null; - } + track('Session Insights', { props: toStringProps(initialData) }); + + let performanceReported = false; + return (data) => { + if (performanceReported) { + return; + } + performanceReported = true; + track('Performance Insights', { props: toStringProps(data) }); + }; }; -const createSession = async (data: any): Promise => { - const response = await sendPostRequest(`${baseUri}/${type}/sessions/`, data); - const { sessionId } = await response.json(); - return sessionId; -}; - -const createFrame = (sessionId: string, data: any): Promise => - sendPostRequest(`${baseUri}/${type}/sessions/${sessionId}`, data).catch(); - -const sendPostRequest = async (uri: string, data: any): Promise => - await fetch(uri, { - method: 'POST', - mode: 'cors', - redirect: 'follow', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(data), - }); +const toStringProps = (data: Record): Record => + Object.entries(data).reduce>((props, [key, value]) => { + if (value !== undefined && value !== null) { + props[key] = String(value); + } + return props; + }, {}); diff --git a/src/index.ts b/src/index.ts index 791d98b..a81d882 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,11 +16,12 @@ import { Random } from './helper/random'; import { removeUnnecessaryOutlines } from './helper/remove-unnecessary-outlines'; import { BlobScene } from './scenes/blob/blob-scene'; import { MetaballScene } from './scenes/metaball/metaball-scene'; +import { OrbitScene } from './scenes/orbit/orbit-scene'; import { RainScene } from './scenes/rain/rain-scene'; import { TunnelScene } from './scenes/tunnel-scene'; import './styles/index.scss'; -const scenes = [TunnelScene, MetaballScene, RainScene, BlobScene]; +const scenes = [TunnelScene, MetaballScene, OrbitScene, RainScene, BlobScene]; Random.seed = 2; glMatrix.setMatrixArrayType(Array); diff --git a/src/scenes/metaball/metaball-scene.ts b/src/scenes/metaball/metaball-scene.ts index 8d17a87..5cd9c42 100644 --- a/src/scenes/metaball/metaball-scene.ts +++ b/src/scenes/metaball/metaball-scene.ts @@ -74,17 +74,21 @@ export class MetaballScene implements Scene { renderer.addDrawable(c.shape); }); + const sweep = Math.sin(currentTime / 1500) * 0.5; const light1 = new Flashlight( vec2.fromValues(-0.05, -0.05), rgb255(104, 171, 212), 0.02, - vec2.fromValues(1, 1) + vec2.fromValues(Math.cos(Math.PI / 4 + sweep), Math.sin(Math.PI / 4 + sweep)) ); const light2 = new Flashlight( vec2.fromValues(viewAreaWidth + 0.05, -0.05), rgb255(226, 90, 102), 0.02, - vec2.fromValues(-1, 1) + vec2.fromValues( + Math.cos((Math.PI * 3) / 4 - sweep), + Math.sin((Math.PI * 3) / 4 - sweep) + ) ); renderer.addDrawable(light1); diff --git a/src/scenes/orbit/body.ts b/src/scenes/orbit/body.ts new file mode 100644 index 0000000..43b7d0d --- /dev/null +++ b/src/scenes/orbit/body.ts @@ -0,0 +1,94 @@ +import { mat2d, vec2 } from 'gl-matrix'; +import { Drawable, DrawableDescriptor } from 'sdf-2d'; + +/** + * A rocky celestial body: a circle whose radius is modulated by a few angular + * harmonics so its outline looks bumpy. One descriptor renders every body in + * the scene (sun, planets, asteroids) — each instance carries its own palette + * colour, size, surface `seed` (rotate it over time to make the body spin) and + * `roughness` (how jagged the outline is: ~0.5 for a planet, ~2.5 for an + * asteroid). + */ +export class Body extends Drawable { + // The peak fraction by which the harmonics below can push the outline + // outward (0.06 + 0.04 + 0.02), used for a conservative culling bound. + private static readonly MAX_WOBBLE = 0.12; + + public static descriptor: DrawableDescriptor = { + sdf: { + shader: ` + uniform vec2 bodyCenters[BODY_COUNT]; + uniform float bodyRadii[BODY_COUNT]; + uniform float bodyColorIndices[BODY_COUNT]; + uniform float bodySeeds[BODY_COUNT]; + uniform float bodyRoughness[BODY_COUNT]; + + float bodyMinDistance(vec2 target, out vec4 color) { + color = readFromPalette(0); + float minDistance = 1000.0; + + for (int i = 0; i < BODY_COUNT; i++) { + vec2 d = target - bodyCenters[i]; + float angle = atan(d.y, d.x); + float seed = bodySeeds[i]; + + float wobble = + sin(angle * 3.0 + seed) * 0.06 + + sin(angle * 7.0 + seed * 1.7) * 0.04 + + sin(angle * 17.0 - seed * 0.5) * 0.02; + + float dist = + length(d) - bodyRadii[i] * (1.0 + wobble * bodyRoughness[i]); + + if (dist < minDistance) { + minDistance = dist; + color = readFromPalette(int(bodyColorIndices[i] + 0.5)); + } + } + + return minDistance; + } + `, + distanceFunctionName: 'bodyMinDistance', + }, + propertyUniformMapping: { + center: 'bodyCenters', + radius: 'bodyRadii', + colorIndex: 'bodyColorIndices', + seed: 'bodySeeds', + roughness: 'bodyRoughness', + }, + uniformCountMacroName: 'BODY_COUNT', + shaderCombinationSteps: [0, 4, 8, 16, 32], + empty: new Body(vec2.create(), 0, 0, 0, 0), + }; + + constructor( + public center: vec2, + public radius: number, + public colorIndex: number, + public seed: number, + public roughness: number + ) { + super(); + } + + public minDistance(target: vec2): number { + // Conservative: bound by the most outward the bumpy outline can reach so a + // body is never culled from a tile its surface might poke into. + return ( + vec2.dist(this.center, target) - + this.radius * (1 + Body.MAX_WOBBLE * this.roughness) + ); + } + + protected getObjectToSerialize(transform2d: mat2d, transform1d: number): any { + return { + center: vec2.transformMat2d(vec2.create(), this.center, transform2d), + radius: this.radius * transform1d, + colorIndex: this.colorIndex, + seed: this.seed, + roughness: this.roughness, + }; + } +} diff --git a/src/scenes/orbit/orbit-scene.ts b/src/scenes/orbit/orbit-scene.ts new file mode 100644 index 0000000..e494c75 --- /dev/null +++ b/src/scenes/orbit/orbit-scene.ts @@ -0,0 +1,169 @@ +import { vec2 } from 'gl-matrix'; +import { CircleLight, hsl, Renderer, rgb, runAnimation } from 'sdf-2d'; +import { prettyPrint } from '../../helper/pretty-print'; +import { Random } from '../../helper/random'; +import { settings } from '../../settings'; +import { Scene } from '../scene'; +import { Body } from './body'; + +const PLANET_COUNT = 6; +const ASTEROID_COUNT = 18; + +// Palette indices used for the bodies (see colorPalette below). +const SUN_COLOR = 5; // warm gold +const ROCK_COLOR = 7; // brown-grey rock +const PLANET_COLORS = [1, 2, 3, 4, 6]; // red, green, blue, purple, pink + +interface OrbitingBody { + drawable: InstanceType; + radiusRatio: number; // orbit radius as a fraction of the system radius + phase: number; + speed: number; + sizeRatio: number; // body radius as a fraction of the system radius + spin: number; // how fast the surface seed rotates + seedPhase: number; +} + +export class OrbitScene implements Scene { + private overlay: HTMLDivElement; + public insights?: any; + + // The sun is a bright solid body so it's actually visible: in this engine a + // light only shows where it reflects off a surface, so a bare light over the + // near-black "space" background would be invisible. The light sources sit at + // its centre and illuminate the orbiting bodies. Because the sun is small + // relative to the orbits, it only mildly occludes its own light. + private sun = new Body(vec2.create(), 0, SUN_COLOR, 0, 0.6); + private sunCore = new CircleLight(vec2.create(), rgb(1.0, 0.96, 0.85), 0.05); + private sunGlow = new CircleLight(vec2.create(), rgb(1.0, 0.62, 0.26), 0.55); + + private bodies: Array = []; + + public async run(canvas: HTMLCanvasElement, overlay: HTMLDivElement): Promise { + this.overlay = overlay; + + for (let i = 0; i < PLANET_COUNT; i++) { + const radiusRatio = 0.26 + (0.64 / PLANET_COUNT) * i; + this.bodies.push({ + drawable: new Body( + vec2.create(), + 0, + PLANET_COLORS[i % PLANET_COLORS.length], + Random.getRandom() * 10, + 1.0 + ), + radiusRatio, + phase: Random.getRandom() * Math.PI * 2, + speed: 0.5 / Math.pow(radiusRatio, 1.5), + sizeRatio: 0.04 + 0.022 * (i % 3), + spin: Random.getRandomInRange(-0.5, 0.5), + seedPhase: Random.getRandom() * 10, + }); + } + + // An asteroid belt: a ring of small, jagged, fast-tumbling rocks. + for (let i = 0; i < ASTEROID_COUNT; i++) { + const radiusRatio = Random.getRandomInRange(0.5, 0.62); + this.bodies.push({ + drawable: new Body( + vec2.create(), + 0, + ROCK_COLOR, + Random.getRandom() * 10, + 2.6 + ), + radiusRatio, + phase: Random.getRandom() * Math.PI * 2, + speed: 0.5 / Math.pow(radiusRatio, 1.5), + sizeRatio: Random.getRandomInRange(0.014, 0.026), + spin: (Random.getRandom() > 0.5 ? 1 : -1) * Random.getRandomInRange(0.8, 2.2), + seedPhase: Random.getRandom() * 10, + }); + } + + const bodyCount = this.bodies.length + 1; // + the sun + + await runAnimation( + canvas, + [ + { + ...Body.descriptor, + shaderCombinationSteps: [0, 4, 8, 16, bodyCount], + }, + { + ...CircleLight.descriptor, + shaderCombinationSteps: [0, 2], + }, + ], + this.drawNextFrame.bind(this), + { + enableHighDpiRendering: true, + motionBlur: 0.6, + ambientLight: rgb(0.17, 0.17, 0.23), + backgroundColor: rgb(0.02, 0.02, 0.05), + colorPalette: [ + rgb(1, 1, 1), // 0 white (unused fallback) + hsl(8, 85, 62), // 1 red + hsl(150, 65, 55), // 2 green + hsl(205, 85, 62), // 3 blue + hsl(275, 70, 68), // 4 purple + hsl(40, 95, 60), // 5 gold (sun) + hsl(330, 80, 65), // 6 pink + hsl(28, 25, 45), // 7 brown-grey rock + ], + } + ); + } + + private drawNextFrame( + renderer: Renderer, + currentTime: DOMHighResTimeStamp, + _: DOMHighResTimeStamp + ): boolean { + this.insights = renderer.insights; + + const width = renderer.canvasSize.x; + const height = renderer.canvasSize.y; + const maxSide = Math.max(width, height); + + const viewAreaWidth = width / maxSide; + const viewAreaHeight = height / maxSide; + renderer.setViewArea( + vec2.fromValues(0, viewAreaHeight), + vec2.fromValues(viewAreaWidth, viewAreaHeight) + ); + + this.overlay.innerText = prettyPrint(renderer.insights); + + const center = vec2.fromValues(viewAreaWidth / 2, viewAreaHeight / 2); + const maxRadius = Math.min(viewAreaWidth, viewAreaHeight) / 2; + const time = currentTime / 1000; + + // The sun sits in the middle; the light sources live at its centre and a + // gentle pulse makes the corona breathe. + vec2.copy(this.sun.center, center); + this.sun.radius = maxRadius * 0.13; + this.sun.seed = time * 0.15; + vec2.copy(this.sunCore.center, center); + vec2.copy(this.sunGlow.center, center); + this.sunGlow.intensity = 0.55 + 0.05 * Math.sin(time * 1.7); + + this.bodies.forEach((body) => { + const angle = body.phase + time * body.speed; + vec2.set( + body.drawable.center, + center.x + Math.cos(angle) * body.radiusRatio * maxRadius, + center.y + Math.sin(angle) * body.radiusRatio * maxRadius + ); + body.drawable.radius = body.sizeRatio * maxRadius; + body.drawable.seed = body.seedPhase + time * body.spin; + }); + + renderer.addDrawable(this.sun); + this.bodies.forEach((body) => renderer.addDrawable(body.drawable)); + renderer.addDrawable(this.sunCore); + renderer.addDrawable(this.sunGlow); + + return currentTime < settings.sceneTimeInMilliseconds; + } +} diff --git a/src/scenes/rain/droplet.ts b/src/scenes/rain/droplet.ts index 2f1977e..8acf11a 100644 --- a/src/scenes/rain/droplet.ts +++ b/src/scenes/rain/droplet.ts @@ -6,34 +6,41 @@ export const Droplet = DropletFactory(rgb255(122, 122, 255)); export class DropletWrapper { public readonly drawable: InstanceType; - private speed = Random.getRandom() * 0.2 + 0.2; + private speed = Random.getRandom() * 0.45 + 0.45; private position = vec2.fromValues( Random.getRandomInRange(0.1, 0.9), Random.getRandom() ); - private length = Random.getRandom() * 20 + 4; + private length = Random.getRandom() * 14 + 8; constructor() { - const size = Random.getRandom() * 2 + 2; + const size = Random.getRandom() * 1.2 + 1; this.drawable = new Droplet( vec2.create(), vec2.create(), - size + Random.getRandom() * 2 + 2, + size + Random.getRandom() + 1, size ); } - public animate(currentTime: number, viewAreaSize: ReadonlyVec2) { + public animate(currentTime: number, viewAreaSize: ReadonlyVec2, windSlant: number) { const heightOffset = 100; + const fall = this.speed * currentTime; + vec2.set( this.drawable.from, - this.position.x * viewAreaSize.x, + (this.position.x * viewAreaSize.x + fall * windSlant) % viewAreaSize.x, viewAreaSize.y - - ((this.position.y * viewAreaSize.y + this.speed * currentTime) % - (viewAreaSize.y + heightOffset)) + ((this.position.y * viewAreaSize.y + fall) % (viewAreaSize.y + heightOffset)) ); - vec2.add(this.drawable.to, this.drawable.from, vec2.fromValues(0, this.length)); + // the tail points opposite to the direction of motion + const norm = this.length / Math.sqrt(1 + windSlant * windSlant); + vec2.add( + this.drawable.to, + this.drawable.from, + vec2.fromValues(-windSlant * norm, norm) + ); } } diff --git a/src/scenes/rain/rain-scene.ts b/src/scenes/rain/rain-scene.ts index 72c8adc..6198fe3 100644 --- a/src/scenes/rain/rain-scene.ts +++ b/src/scenes/rain/rain-scene.ts @@ -5,17 +5,26 @@ import { settings } from '../../settings'; import { Scene } from '../scene'; import { Droplet, DropletWrapper } from './droplet'; +const WIND_SLANT = 0.2; + export class RainScene implements Scene { private droplets: Array = []; - private light1: CircleLight = new CircleLight(vec2.create(), rgb255(184, 41, 255), 1.5); - private light2: CircleLight = new CircleLight(vec2.create(), rgb255(255, 31, 109), 1.5); + private lights = [ + new CircleLight(vec2.create(), rgb255(184, 41, 255), 1.5), + new CircleLight(vec2.create(), rgb255(255, 31, 109), 1.5), + new CircleLight(vec2.create(), rgb255(64, 110, 255), 1.5), + ]; private overlay: HTMLDivElement; public insights?: any; public async run(canvas: HTMLCanvasElement, overlay: HTMLDivElement): Promise { this.overlay = overlay; - for (let i = 0; i < (canvas.getBoundingClientRect().width / 800) * 20; i++) { + for ( + let i = 0; + i < Math.max(100, (canvas.getBoundingClientRect().width / 800) * 100); + i++ + ) { this.droplets.push(new DropletWrapper()); } @@ -24,18 +33,24 @@ export class RainScene implements Scene { [ { ...Droplet.descriptor, - shaderCombinationSteps: [0, 1, 2, 4, 8, 12, 16, 24], + // Tiles that contain more droplets than the largest step have no + // compiled shader to fall back on and flicker; together with the + // raised tileMultiplier, 48 keeps the worst-case tile comfortably + // covered. + shaderCombinationSteps: [0, 2, 4, 8, 16, 32, 48], }, { ...CircleLight.descriptor, - shaderCombinationSteps: [0, 2], + shaderCombinationSteps: [0, 3], }, ], this.drawNextFrame.bind(this), { backgroundColor: rgb(0.5, 0.5, 0.5), - ambientLight: rgb(0.2, 0.2, 0.2), + ambientLight: rgb(0.2, 0.2, 0.23), enableHighDpiRendering: true, + // More, smaller tiles keep the droplet count per tile low. + tileMultiplier: 12, } ); } @@ -55,12 +70,19 @@ export class RainScene implements Scene { const viewAreaSize = renderer.viewAreaSize; - vec2.set(this.light1.center, 0, viewAreaSize.y / 2); - vec2.set(this.light2.center, viewAreaSize.x, viewAreaSize.y / 2); + // each light sweeps within its own third of the screen + this.lights.forEach((light, i) => { + vec2.set( + light.center, + viewAreaSize.x * + ((i + 0.5) / 3 + (1 / 6) * Math.sin(currentTime / 900 + (i * Math.PI * 2) / 3)), + 0 + ); + }); - this.droplets.forEach((d) => d.animate(currentTime, viewAreaSize)); + this.droplets.forEach((d) => d.animate(currentTime, viewAreaSize, WIND_SLANT)); - [...this.droplets.map((d) => d.drawable), this.light1, this.light2].forEach((d) => + [...this.droplets.map((d) => d.drawable), ...this.lights].forEach((d) => renderer.addDrawable(d) );