Compare commits

..

No commits in common. "d8cbffcfd523460343c9b8e2304bf1189802d83e" and "414149293abcdc427d9ee50f1490bd33a7338cf3" have entirely different histories.

12 changed files with 92 additions and 369 deletions

5
.firebaserc Normal file
View file

@ -0,0 +1,5 @@
{
"projects": {
"default": "schmelczer-dev"
}
}

View file

@ -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'

17
firebase.json Normal file
View file

@ -0,0 +1,17 @@
{
"hosting": {
"public": "dist",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"site": "sdf2d-schmelczer-dev",
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
}
}

48
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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;
try {
const sessionId = await createSession(initialData);
return (data) => createFrame(sessionId, data);
} catch {
return () => null;
}
performanceReported = true;
track('Performance Insights', { props: toStringProps(data) });
};
};
const toStringProps = (data: Record<string, unknown>): Record<string, string> =>
Object.entries(data).reduce<Record<string, string>>((props, [key, value]) => {
if (value !== undefined && value !== null) {
props[key] = String(value);
}
return props;
}, {});
const createSession = async (data: any): Promise<string> => {
const response = await sendPostRequest(`${baseUri}/${type}/sessions/`, data);
const { sessionId } = await response.json();
return sessionId;
};
const createFrame = (sessionId: string, data: any): Promise<unknown> =>
sendPostRequest(`${baseUri}/${type}/sessions/${sessionId}`, data).catch();
const sendPostRequest = async (uri: string, data: any): Promise<Response> =>
await fetch(uri, {
method: 'POST',
mode: 'cors',
redirect: 'follow',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});

View file

@ -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);

View file

@ -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);

View file

@ -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,
};
}
}

View file

@ -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<typeof Body>;
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<OrbitingBody> = [];
public async run(canvas: HTMLCanvasElement, overlay: HTMLDivElement): Promise<void> {
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;
}
}

View file

@ -6,41 +6,34 @@ export const Droplet = DropletFactory(rgb255(122, 122, 255));
export class DropletWrapper {
public readonly drawable: InstanceType<typeof Droplet>;
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));
}
}

View file

@ -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<DropletWrapper> = [];
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<void> {
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)
);