diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 0000000..d585736 --- /dev/null +++ b/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "schmelczer-dev" + } +} diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 3d73601..22aa2b5 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -28,7 +28,10 @@ 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 new file mode 100644 index 0000000..5917d4b --- /dev/null +++ b/firebase.json @@ -0,0 +1,17 @@ +{ + "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 a8ecde4..5692bb2 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.8.0", + "sdf-2d": "^0.7.6", "source-map-loader": "^5.0.0", "ts-loader": "^9.6.0", "typescript": "^6.0.3", @@ -39,34 +39,6 @@ "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", @@ -6694,6 +6666,13 @@ "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", @@ -6940,8 +6919,15 @@ "license": "MIT" }, "node_modules/sdf-2d": { - "resolved": "../sdf-2d", - "link": true + "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" + } }, "node_modules/select-hose": { "version": "2.0.0", diff --git a/package.json b/package.json index 04a34f1..2a448de 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.8.0", + "sdf-2d": "^0.7.6", "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 6c5f85d..6dfe73a 100644 --- a/src/helper/handle-insights.ts +++ b/src/helper/handle-insights.ts @@ -1,22 +1,31 @@ -import { track } from '../analytics'; +const baseUri = 'https://insights.decla.red'; +const type = 'sdf-2d-demo'; export const handleInsights = async (initialData: any): Promise<(data: any) => void> => { - track('Session Insights', { props: toStringProps(initialData) }); - - let performanceReported = false; - return (data) => { - if (performanceReported) { - return; - } - performanceReported = true; - track('Performance Insights', { props: toStringProps(data) }); - }; + try { + const sessionId = await createSession(initialData); + return (data) => createFrame(sessionId, data); + } catch { + return () => null; + } }; -const toStringProps = (data: Record): Record => - Object.entries(data).reduce>((props, [key, value]) => { - if (value !== undefined && value !== null) { - props[key] = String(value); - } - return props; - }, {}); +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), + }); diff --git a/src/index.ts b/src/index.ts index a81d882..791d98b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,12 +16,11 @@ 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, OrbitScene, RainScene, BlobScene]; +const scenes = [TunnelScene, MetaballScene, 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 5cd9c42..8d17a87 100644 --- a/src/scenes/metaball/metaball-scene.ts +++ b/src/scenes/metaball/metaball-scene.ts @@ -74,21 +74,17 @@ 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(Math.cos(Math.PI / 4 + sweep), Math.sin(Math.PI / 4 + sweep)) + vec2.fromValues(1, 1) ); const light2 = new Flashlight( vec2.fromValues(viewAreaWidth + 0.05, -0.05), rgb255(226, 90, 102), 0.02, - vec2.fromValues( - Math.cos((Math.PI * 3) / 4 - sweep), - Math.sin((Math.PI * 3) / 4 - sweep) - ) + vec2.fromValues(-1, 1) ); renderer.addDrawable(light1); diff --git a/src/scenes/orbit/body.ts b/src/scenes/orbit/body.ts deleted file mode 100644 index 43b7d0d..0000000 --- a/src/scenes/orbit/body.ts +++ /dev/null @@ -1,94 +0,0 @@ -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 deleted file mode 100644 index e494c75..0000000 --- a/src/scenes/orbit/orbit-scene.ts +++ /dev/null @@ -1,169 +0,0 @@ -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 8acf11a..2f1977e 100644 --- a/src/scenes/rain/droplet.ts +++ b/src/scenes/rain/droplet.ts @@ -6,41 +6,34 @@ export const Droplet = DropletFactory(rgb255(122, 122, 255)); export class DropletWrapper { public readonly drawable: InstanceType; - private speed = Random.getRandom() * 0.45 + 0.45; + private speed = Random.getRandom() * 0.2 + 0.2; private position = vec2.fromValues( Random.getRandomInRange(0.1, 0.9), Random.getRandom() ); - private length = Random.getRandom() * 14 + 8; + private length = Random.getRandom() * 20 + 4; constructor() { - const size = Random.getRandom() * 1.2 + 1; + const size = Random.getRandom() * 2 + 2; this.drawable = new Droplet( vec2.create(), vec2.create(), - size + Random.getRandom() + 1, + size + Random.getRandom() * 2 + 2, size ); } - public animate(currentTime: number, viewAreaSize: ReadonlyVec2, windSlant: number) { + public animate(currentTime: number, viewAreaSize: ReadonlyVec2) { const heightOffset = 100; - const fall = this.speed * currentTime; - vec2.set( this.drawable.from, - (this.position.x * viewAreaSize.x + fall * windSlant) % viewAreaSize.x, + this.position.x * viewAreaSize.x, viewAreaSize.y - - ((this.position.y * viewAreaSize.y + fall) % (viewAreaSize.y + heightOffset)) + ((this.position.y * viewAreaSize.y + this.speed * currentTime) % + (viewAreaSize.y + heightOffset)) ); - // 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) - ); + vec2.add(this.drawable.to, this.drawable.from, vec2.fromValues(0, this.length)); } } diff --git a/src/scenes/rain/rain-scene.ts b/src/scenes/rain/rain-scene.ts index 6198fe3..72c8adc 100644 --- a/src/scenes/rain/rain-scene.ts +++ b/src/scenes/rain/rain-scene.ts @@ -5,26 +5,17 @@ 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 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 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 overlay: HTMLDivElement; public insights?: any; public async run(canvas: HTMLCanvasElement, overlay: HTMLDivElement): Promise { this.overlay = overlay; - for ( - let i = 0; - i < Math.max(100, (canvas.getBoundingClientRect().width / 800) * 100); - i++ - ) { + for (let i = 0; i < (canvas.getBoundingClientRect().width / 800) * 20; i++) { this.droplets.push(new DropletWrapper()); } @@ -33,24 +24,18 @@ export class RainScene implements Scene { [ { ...Droplet.descriptor, - // 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], + shaderCombinationSteps: [0, 1, 2, 4, 8, 12, 16, 24], }, { ...CircleLight.descriptor, - shaderCombinationSteps: [0, 3], + shaderCombinationSteps: [0, 2], }, ], this.drawNextFrame.bind(this), { backgroundColor: rgb(0.5, 0.5, 0.5), - ambientLight: rgb(0.2, 0.2, 0.23), + ambientLight: rgb(0.2, 0.2, 0.2), enableHighDpiRendering: true, - // More, smaller tiles keep the droplet count per tile low. - tileMultiplier: 12, } ); } @@ -70,19 +55,12 @@ export class RainScene implements Scene { const viewAreaSize = renderer.viewAreaSize; - // 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 - ); - }); + vec2.set(this.light1.center, 0, viewAreaSize.y / 2); + vec2.set(this.light2.center, viewAreaSize.x, viewAreaSize.y / 2); - this.droplets.forEach((d) => d.animate(currentTime, viewAreaSize, WIND_SLANT)); + this.droplets.forEach((d) => d.animate(currentTime, viewAreaSize)); - [...this.droplets.map((d) => d.drawable), ...this.lights].forEach((d) => + [...this.droplets.map((d) => d.drawable), this.light1, this.light2].forEach((d) => renderer.addDrawable(d) );