entangled-worlds-orbiters-shared 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "entangled-worlds-orbiters-shared",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Shared library for entangled worlds orbiters - contains core world rendering, moons system, and UI components",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -8,6 +8,7 @@
8
8
  "exports": {
9
9
  ".": "./src/index.js",
10
10
  "./world": "./src/world/index.js",
11
+ "./minimal": "./src/minimal/index.js",
11
12
  "./moons": "./src/moons/index.js",
12
13
  "./loaders": "./src/loaders/index.js",
13
14
  "./render": "./src/render/index.js",
@@ -42,6 +43,7 @@
42
43
  "files": [
43
44
  "src/**/*",
44
45
  "!src/**/*.test.ts",
46
+ "!src/**/*.test.js",
45
47
  "dist/**/*",
46
48
  "public/**/*",
47
49
  "README.md",
@@ -90,6 +92,8 @@
90
92
  "typecheck": "tsc -p tsconfig.json --noEmit",
91
93
  "test:clock": "node --test --import tsx src/clock/*.test.ts",
92
94
  "test:render": "node --test src/render/*.test.js",
93
- "test": "npm run test:clock && npm run test:render"
95
+ "test:minimal": "node --test src/minimal/*.test.js",
96
+ "test": "npm run test:clock && npm run test:render && npm run test:minimal && npm run test:moons",
97
+ "test:moons": "node --test src/moons/*.test.js"
94
98
  }
95
99
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The minimal ("performance mode") look: one cheap lit sphere, one key light, a weak ambient fill,
3
+ * and nothing else — no texture, no halo, no cloud shell, no skybox.
4
+ *
5
+ * Its own entry point rather than a few more names on `./world`, because both consumers want exactly
6
+ * this and nothing around it: the Orbiters renderer and the Entangled Worlds editor's performance
7
+ * preview. Keeping it separate means importing the look never drags in the texture/GLB/cloud loaders
8
+ * that the look exists to avoid.
9
+ */
10
+ export {
11
+ createMinimalWorld,
12
+ resolvePlanetColor,
13
+ MINIMAL_WORLD_MODE,
14
+ MINIMAL_MATERIAL_KINDS,
15
+ MINIMAL_PLANET_COLOR_HEX,
16
+ } from './minimalWorld.js';
17
+
18
+ export {
19
+ applyMinimalLighting,
20
+ removeMinimalLighting,
21
+ updateMinimalKeyLight,
22
+ takeSceneLights,
23
+ DEFAULT_LIGHT_NAMES,
24
+ MINIMAL_LIGHT_NAME,
25
+ MINIMAL_FILL_NAME,
26
+ } from './minimalLighting.js';
@@ -0,0 +1,137 @@
1
+ /**
2
+ * @file src/minimal/minimalLighting.js
3
+ * @description The one light the minimal look keeps.
4
+ *
5
+ * Shared for the same reason as `minimalWorld.js`, and more sharply: lighting is what makes two
6
+ * renders of the same world look like the same world. The editor's performance preview and the
7
+ * Orbiters render MUST take their key light, their fill and their restore behaviour from this one
8
+ * module, or an author would tune a world against lighting the public never sees.
9
+ *
10
+ * Minimal is not an unlit scene. The moons are a lit material, so a scene with no lights renders them
11
+ * as black discs, and the planet needs a light for its shape to be legible at all. What it does not
12
+ * need is the world's usual pair — a hemisphere and an ambient, both white-on-white at full strength,
13
+ * which light every surface identically and are themselves part of why a sphere reads as a flat disc.
14
+ *
15
+ * So: exactly one directional light, no shadows. Its colour and direction come from the world's own
16
+ * authored lighting where the world provides them, which is how a minimal scene still carries some of
17
+ * the world's identity without loading anything.
18
+ */
19
+ import * as THREE from 'three';
20
+
21
+ /** The shared default lights, by the names the shared package gives them. */
22
+ export const DEFAULT_LIGHT_NAMES = Object.freeze(['ew_HemisphereLight', 'ew_AmbientLight']);
23
+ /** The single light the minimal look keeps. */
24
+ export const MINIMAL_LIGHT_NAME = 'orb_MinimalKeyLight';
25
+ /** The weak ambient that keeps the unlit side from going pure black. */
26
+ export const MINIMAL_FILL_NAME = 'orb_MinimalFillLight';
27
+
28
+ const DEFAULT_INTENSITY = 1.8;
29
+ const DEFAULT_DIRECTION = Object.freeze({ x: 1, y: 0.6, z: 1 });
30
+ /**
31
+ * The fill. A single directional light alone puts the unlit side at pure black, which cuts the
32
+ * silhouette in half instead of rounding it — the terminator reads as a hard edge rather than a
33
+ * curve. A weak ambient lifts that side just enough for the shape to close.
34
+ *
35
+ * Ambient is the cheapest light there is: a constant added per fragment, no direction, no attenuation,
36
+ * no extra pass, and no shadow map. It is the right way to soften this and the only one worth its cost.
37
+ */
38
+ const FILL_INTENSITY = 0.55;
39
+
40
+ function removeLightByName(scene, name) {
41
+ const light = scene.getObjectByName(name);
42
+ if (!light) return;
43
+ scene.remove(light);
44
+ // Lights hold no geometry or texture, but they do hold a shadow map + camera once shadows have
45
+ // been on, and `dispose` is what releases it.
46
+ light.dispose?.();
47
+ }
48
+
49
+ /**
50
+ * Detach the world's default lights and hand them back — WITHOUT disposing them.
51
+ *
52
+ * They carry the world's authored colour, direction and intensities, and nothing re-applies those
53
+ * when the mode is switched off inside Studio. Keeping the objects means leaving minimal restores
54
+ * exactly what the world had, rather than a generic pair at 1.0/1.0.
55
+ *
56
+ * @param {THREE.Scene} scene
57
+ * @returns {THREE.Light[]} the detached lights, in the order they were found
58
+ */
59
+ export function takeSceneLights(scene) {
60
+ if (!scene) return [];
61
+ const taken = [];
62
+ DEFAULT_LIGHT_NAMES.forEach((name) => {
63
+ const light = scene.getObjectByName(name);
64
+ if (!light) return;
65
+ scene.remove(light);
66
+ taken.push(light);
67
+ });
68
+ return taken;
69
+ }
70
+
71
+ /**
72
+ * Add the key light plus a weak ambient fill. Idempotent. Two lights, both of the cheapest kinds,
73
+ * and neither casting a shadow.
74
+ *
75
+ * The world's own lights are NOT removed here — that is `takeSceneLights`, so the caller decides
76
+ * whether it is keeping them to put back.
77
+ */
78
+ export function applyMinimalLighting(scene) {
79
+ if (!scene) return null;
80
+
81
+ if (!scene.getObjectByName(MINIMAL_FILL_NAME)) {
82
+ const fill = new THREE.AmbientLight(0xffffff, FILL_INTENSITY);
83
+ fill.name = MINIMAL_FILL_NAME;
84
+ scene.add(fill);
85
+ }
86
+
87
+ const existing = scene.getObjectByName(MINIMAL_LIGHT_NAME);
88
+ if (existing) return existing;
89
+ const key = new THREE.DirectionalLight(0xffffff, DEFAULT_INTENSITY);
90
+ key.position.set(DEFAULT_DIRECTION.x, DEFAULT_DIRECTION.y, DEFAULT_DIRECTION.z);
91
+ key.castShadow = false;
92
+ key.name = MINIMAL_LIGHT_NAME;
93
+ scene.add(key);
94
+ return key;
95
+ }
96
+
97
+ export function removeMinimalLighting(scene) {
98
+ if (!scene) return;
99
+ removeLightByName(scene, MINIMAL_LIGHT_NAME);
100
+ removeLightByName(scene, MINIMAL_FILL_NAME);
101
+ }
102
+
103
+ /**
104
+ * Point the key light the way the world says its light points.
105
+ *
106
+ * DIRECTION only — the world's light COLOUR is deliberately not applied. Minimal is a monochrome
107
+ * look, and a world that authored a strongly tinted light would drag the whole scene into that hue
108
+ * and break the consistency the mode is for. Direction gives the shape somewhere to be lit from,
109
+ * which is what actually varies usefully between worlds.
110
+ *
111
+ * A no-op when the key light is not in the scene, so a full-profile world load never has to check
112
+ * which mode it is in.
113
+ *
114
+ * @param {THREE.Scene} scene
115
+ * @param {{ lightDirection?: object, direction?: object,
116
+ * diffuseIntensity?: number, diffuse?: number }} lighting
117
+ */
118
+ export function updateMinimalKeyLight(scene, lighting = {}) {
119
+ const key = scene?.getObjectByName(MINIMAL_LIGHT_NAME);
120
+ if (!key) return;
121
+
122
+ const direction = lighting.lightDirection ?? lighting.direction;
123
+ if (direction && typeof direction === 'object') {
124
+ const x = Number.isFinite(direction.x) ? direction.x : DEFAULT_DIRECTION.x;
125
+ const y = Number.isFinite(direction.y) ? direction.y : DEFAULT_DIRECTION.y;
126
+ const z = Number.isFinite(direction.z) ? direction.z : DEFAULT_DIRECTION.z;
127
+ // A direction of all zeros would put the light inside the planet and black the scene out.
128
+ if (x !== 0 || y !== 0 || z !== 0) key.position.set(x, y, z);
129
+ }
130
+
131
+ const intensity = lighting.diffuseIntensity ?? lighting.diffuse;
132
+ if (Number.isFinite(intensity) && intensity > 0) {
133
+ // The world's intensities are authored against TWO lights at full strength; this is one, so it
134
+ // keeps the default as a floor rather than dimming the only light in the scene.
135
+ key.intensity = Math.max(DEFAULT_INTENSITY, intensity);
136
+ }
137
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * @file src/minimal/minimalWorld.js
3
+ * @description The planet as performance mode draws it: one sphere, one cheap lit material, no
4
+ * texture, no halo, no cloud shell.
5
+ *
6
+ * Shared, not Orbiters-local, because TWO surfaces draw this look: Orbiters renders it in
7
+ * performance mode, and the Entangled Worlds editor previews it so an author can see what they are
8
+ * authoring. If each kept its own copy the preview would drift from the render and start lying about
9
+ * what the public will see — the one failure this module exists to prevent.
10
+ *
11
+ * Why this exists rather than reusing the shared normal-material sphere: `MeshNormalMaterial` colours
12
+ * each pixel by the direction the surface faces, which is a gradient, not shading. It cannot respond
13
+ * to a light at all — so however many lights the scene has, the planet reads as a flat disc while the
14
+ * moons beside it (a lit material) read as spheres. A lit material is what makes the shape legible.
15
+ *
16
+ * The cost of that is close to nothing. Lambert is one dot product per fragment against a single
17
+ * directional light — no specular, no roughness, no environment lookup, no texture fetch. What it
18
+ * costs over the normal material is far less than what a texture, a halo or a cloud shell cost, all
19
+ * of which this mode drops.
20
+ *
21
+ * `kind` exists so the options can be compared on real hardware without a rebuild
22
+ * (`?minimalMaterial=lambert|phong|normal`), and so the choice is one line if a cheaper one wins.
23
+ */
24
+ import * as THREE from 'three';
25
+
26
+ export const MINIMAL_WORLD_MODE = 90;
27
+
28
+ /**
29
+ * The minimal planet's colour when the world names none.
30
+ *
31
+ * Deliberately neutral, and deliberately NOT read from the `--color*` theme variables: those are the
32
+ * app chrome's palette (white, black, the UI accent), not the world's, so reading them would paint
33
+ * every planet the same interface colour. A world that DOES name a colour is honoured — see
34
+ * `resolvePlanetColor` — and what carries the identity of one that does not is its own authored
35
+ * light (`minimalLighting.js`).
36
+ */
37
+ const DEFAULT_PLANET_COLOR = 0xb8c2cc;
38
+
39
+ /**
40
+ * The same neutral as a CSS hex, for surfaces that have to SHOW it rather than render it — the
41
+ * editor's colour swatch displays this when a world has authored nothing, so the control never
42
+ * claims a colour the renderer is not actually using. Exported so there is one constant, not an
43
+ * editor copy that has to be kept equal to this one.
44
+ */
45
+ export const MINIMAL_PLANET_COLOR_HEX = `#${DEFAULT_PLANET_COLOR.toString(16).padStart(6, '0')}`;
46
+
47
+ /**
48
+ * The floor an authored colour is lifted to, as HSL lightness in the sRGB the author picked in.
49
+ *
50
+ * Minimal drops the skybox, so the planet is drawn against black (`SceneSpace` sets the background to
51
+ * BLACK when there is no cubemap). The scene's whole light budget is one key at 1.8 plus a 0.55
52
+ * ambient fill, so a near-black body reflects almost nothing and disappears into that background
53
+ * entirely — not a dark planet, no planet. Lifting the lightness while keeping hue and saturation
54
+ * means a world that authored near-black still reads as its own hue, just at the darkest level that
55
+ * is still a visible sphere.
56
+ *
57
+ * 0.10 sits below anything an author would recognise as a colour rather than as black: `#101030`, a
58
+ * genuinely dark navy, measures 0.125 and passes through untouched.
59
+ */
60
+ const MIN_PLANET_LIGHTNESS = 0.1;
61
+
62
+ export const MINIMAL_MATERIAL_KINDS = Object.freeze(['lambert', 'phong', 'normal']);
63
+
64
+ /**
65
+ * Segment counts used only when the world names none.
66
+ *
67
+ * The world's OWN resolution is never overridden, including when it is very low. A low-segment world
68
+ * is a faceted world on purpose — that faceting is the world's shape, not a defect to be smoothed
69
+ * away — and performance mode changes what is DRAWN, never the world's configuration.
70
+ */
71
+ const FALLBACK_WIDTH_SEGMENTS = 48;
72
+ const FALLBACK_HEIGHT_SEGMENTS = 32;
73
+
74
+ function resolveSegments(value, fallback) {
75
+ const numeric = Number(value);
76
+ if (!Number.isFinite(numeric) || numeric <= 0) return fallback;
77
+ return Math.round(numeric);
78
+ }
79
+
80
+ /**
81
+ * Parse a colour, or report that it could not be parsed.
82
+ *
83
+ * `new THREE.Color(x)` does NOT throw on a value it cannot read: `Color.set` ignores anything that is
84
+ * not a Color, a number or a string, and `setStyle` warns and leaves the colour untouched. Either way
85
+ * the object keeps whatever it was constructed with, so a `try/catch` around it catches nothing and
86
+ * the caller silently gets that starting value — white, by default.
87
+ *
88
+ * That matters here because the colour arrives from world metadata, which is `Schema.Types.Mixed` and
89
+ * validated nowhere: any non-empty string persists, so `'blueish'` would paint a WHITE planet rather
90
+ * than falling back to the neutral one.
91
+ *
92
+ * So: probe from black. If the result is black the value was either genuinely black or not read at
93
+ * all — probe again from white to tell those apart. The common case parses on the first probe and
94
+ * costs one object.
95
+ *
96
+ * @returns {THREE.Color|null} null when the value could not be read as a colour
97
+ */
98
+ function parseColor(value) {
99
+ const fromBlack = new THREE.Color(0x000000).set(value);
100
+ if (fromBlack.getHex() !== 0x000000) return fromBlack;
101
+ const fromWhite = new THREE.Color(0xffffff).set(value);
102
+ return fromWhite.getHex() === 0x000000 ? fromBlack : null;
103
+ }
104
+
105
+ /**
106
+ * The planet colour to use, given the colour the world authored (if any).
107
+ *
108
+ * The only thing done to an authored colour is the legibility floor above. Hue and saturation are
109
+ * never touched: the point of the control is that the world picks its own colour, so anything that
110
+ * moved it toward a house palette would defeat it.
111
+ *
112
+ * @param {THREE.Color|number|string|null|undefined} override
113
+ * @returns {THREE.Color}
114
+ */
115
+ export function resolvePlanetColor(override) {
116
+ if (override === undefined || override === null) return new THREE.Color(DEFAULT_PLANET_COLOR);
117
+ // An unreadable colour is never worth failing a world load over — the world just keeps the neutral.
118
+ const color = parseColor(override);
119
+ if (!color) return new THREE.Color(DEFAULT_PLANET_COLOR);
120
+ // Read and write HSL in sRGB — the space the author picked the colour in. The working space is
121
+ // linear, where the same swatch measures several times darker, so a floor applied there would
122
+ // lift colours the author would not call dark at all.
123
+ const hsl = { h: 0, s: 0, l: 0 };
124
+ color.getHSL(hsl, THREE.SRGBColorSpace);
125
+ if (hsl.l < MIN_PLANET_LIGHTNESS) {
126
+ color.setHSL(hsl.h, hsl.s, MIN_PLANET_LIGHTNESS, THREE.SRGBColorSpace);
127
+ }
128
+ return color;
129
+ }
130
+
131
+ function createMaterial(kind, color) {
132
+ switch (kind) {
133
+ case 'normal':
134
+ // The previous look, kept so it can be compared directly rather than from memory.
135
+ return new THREE.MeshNormalMaterial();
136
+ case 'phong':
137
+ // One specular highlight on top of Lambert. Reads glossier; costs a little more.
138
+ return new THREE.MeshPhongMaterial({ color, shininess: 12, specular: 0x222222 });
139
+ default:
140
+ return new THREE.MeshLambertMaterial({ color });
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Build the minimal planet. Signature matches what `WorldManager.setMode` hands a custom creator.
146
+ *
147
+ * @param {{
148
+ * sphereRadius?: number,
149
+ * sphereWidthSegments?: number,
150
+ * sphereHeightSegments?: number,
151
+ * materialKind?: 'lambert'|'phong'|'normal',
152
+ * planetColor?: THREE.Color|number|string,
153
+ * }} args
154
+ * @returns {Promise<THREE.Mesh>}
155
+ */
156
+ export async function createMinimalWorld({
157
+ sphereRadius = 1,
158
+ sphereWidthSegments,
159
+ sphereHeightSegments,
160
+ materialKind = 'lambert',
161
+ planetColor,
162
+ } = {}) {
163
+ const kind = MINIMAL_MATERIAL_KINDS.includes(materialKind) ? materialKind : 'lambert';
164
+ const color = resolvePlanetColor(planetColor);
165
+
166
+ // The world's own radius and segment counts, as authored. Same shape as the full look — this mode
167
+ // changes the material and what surrounds the planet, never the geometry the world describes.
168
+ const geometry = new THREE.SphereGeometry(
169
+ sphereRadius,
170
+ resolveSegments(sphereWidthSegments, FALLBACK_WIDTH_SEGMENTS),
171
+ resolveSegments(sphereHeightSegments, FALLBACK_HEIGHT_SEGMENTS),
172
+ );
173
+ const material = createMaterial(kind, color);
174
+ const mesh = new THREE.Mesh(geometry, material);
175
+ mesh.name = 'orb_MinimalWorld';
176
+ mesh.castShadow = false;
177
+ mesh.receiveShadow = false;
178
+ return mesh;
179
+ }
@@ -38,7 +38,6 @@ export async function generateMoons({
38
38
  debugShowReferenceMoon = false, // changed from true to prevent extra moon
39
39
  referenceMoonScale = 0.25,
40
40
  // behavior flags
41
- preserveInstanceTint = false, // keep per-instance color even when textured
42
41
  useEmissiveMapFromTexture = true, // reuse color texture as emissiveMap to lift dark sides
43
42
  // The renderer whose KTX2 transcoder decodes the moon texture. Required on any page with more than
44
43
  // one renderer; falls back to the standalone app's global inside the loader.
@@ -118,10 +117,20 @@ export async function generateMoons({
118
117
  }
119
118
  }
120
119
 
121
- const enableVertexColors = !sharedTexture || preserveInstanceTint;
120
+ // `vertexColors` is deliberately NOT set, and setting it here was a bug worth spelling out.
121
+ //
122
+ // The per-moon tint rides on `instanceColor` (set by `setColorAt` below). Three defines
123
+ // USE_INSTANCING_COLOR for that on its own and multiplies it into `vColor` — no material flag
124
+ // needed. `vertexColors` is a DIFFERENT feature: it multiplies by the geometry's `color`
125
+ // ATTRIBUTE. This geometry is a plain SphereGeometry and has no such attribute, so enabling it
126
+ // made the shader multiply by an absent attribute — zero — and every moon rendered BLACK, with the
127
+ // tint annihilated along with it.
128
+ //
129
+ // It only bit the untextured path (`!sharedTexture`), which is the one the performance look uses:
130
+ // moons there carry no texture, so their colour IS the moon, and it was the one case where the
131
+ // colour silently could not work.
122
132
  const moonMaterial = new THREE.MeshStandardMaterial({
123
133
  map: sharedTexture || null,
124
- vertexColors: enableVertexColors,
125
134
  roughness,
126
135
  metalness,
127
136
  emissive: new THREE.Color(emissiveColor),
@@ -1,62 +0,0 @@
1
- import test from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { createFrameBudget, CAPPED_FPS } from './frameBudget.js';
4
-
5
- test('the first frame is always allowed', () => {
6
- const budget = createFrameBudget();
7
- assert.equal(budget.shouldRender(0), true);
8
- });
9
-
10
- test('a 60Hz display yields 30fps, not 20', () => {
11
- // The whole reason the tolerance exists. rAF on a 60Hz display delivers at ~16.7ms intervals; a
12
- // strict >= 33.3 test rejects the frame at 33.3 and waits for 50, which is 20fps.
13
- const budget = createFrameBudget();
14
- const accepted = [];
15
- for (let i = 0; i < 12; i += 1) {
16
- const now = i * (1000 / 60);
17
- if (budget.shouldRender(now)) accepted.push(i);
18
- }
19
- // Every other frame: 0, 2, 4, ... — half of 60 is the 30 that was asked for.
20
- assert.deepEqual(accepted, [0, 2, 4, 6, 8, 10]);
21
- });
22
-
23
- test('a 120Hz display is capped to the same rate', () => {
24
- const budget = createFrameBudget();
25
- let accepted = 0;
26
- for (let i = 0; i < 120; i += 1) {
27
- if (budget.shouldRender(i * (1000 / 120))) accepted += 1;
28
- }
29
- // One second of 120Hz ticks, capped at 30 — allow the boundary frame either way.
30
- assert.ok(accepted >= CAPPED_FPS && accepted <= CAPPED_FPS + 1, `accepted ${accepted}`);
31
- });
32
-
33
- test('a rejected frame does not move the clock forward', () => {
34
- // Otherwise a burst of rejected frames would keep pushing the next allowed one further out.
35
- const budget = createFrameBudget();
36
- budget.shouldRender(0);
37
- assert.equal(budget.shouldRender(10), false);
38
- assert.equal(budget.shouldRender(20), false);
39
- assert.equal(budget.shouldRender(30), true);
40
- });
41
-
42
- test('reset makes the next frame the first one again', () => {
43
- const budget = createFrameBudget();
44
- budget.shouldRender(0);
45
- assert.equal(budget.shouldRender(5), false);
46
- budget.reset();
47
- assert.equal(budget.shouldRender(5), true);
48
- });
49
-
50
- test('a missing timestamp never blocks a frame', () => {
51
- // rAF always supplies one, but `renderOnce` can be driven by tooling that does not.
52
- const budget = createFrameBudget();
53
- assert.equal(budget.shouldRender(undefined), true);
54
- assert.equal(budget.shouldRender(Number.NaN), true);
55
- });
56
-
57
- test('a custom rate is honoured', () => {
58
- const budget = createFrameBudget({ fps: 10, toleranceMs: 0 });
59
- assert.equal(budget.shouldRender(0), true);
60
- assert.equal(budget.shouldRender(99), false);
61
- assert.equal(budget.shouldRender(100), true);
62
- });