entangled-worlds-orbiters-shared 2.4.2 → 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 +8 -2
- package/src/minimal/index.js +26 -0
- package/src/minimal/minimalLighting.js +137 -0
- package/src/minimal/minimalWorld.js +179 -0
- package/src/moons/moonsGeneration.js +12 -3
- package/src/render/frameBudget.js +46 -0
- package/src/render/index.js +1 -0
- package/src/render/viewportCompositor.js +76 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "entangled-worlds-orbiters-shared",
|
|
3
|
-
"version": "2.
|
|
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",
|
|
@@ -88,6 +90,10 @@
|
|
|
88
90
|
"build:clock": "tsc -p tsconfig.build.json",
|
|
89
91
|
"prepare": "npm run build:clock",
|
|
90
92
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
91
|
-
"test:clock": "node --test --import tsx src/clock/*.test.ts"
|
|
93
|
+
"test:clock": "node --test --import tsx src/clock/*.test.ts",
|
|
94
|
+
"test:render": "node --test src/render/*.test.js",
|
|
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"
|
|
92
98
|
}
|
|
93
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
|
-
|
|
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),
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/render/frameBudget.js
|
|
3
|
+
* @description "Has enough time passed to draw again?" — the whole of a frame cap, kept apart from
|
|
4
|
+
* whatever is being capped so it can be reasoned about and tested on its own.
|
|
5
|
+
*
|
|
6
|
+
* The tolerance is the part that is easy to get wrong. rAF does not deliver frames at arbitrary
|
|
7
|
+
* times; it delivers them on the display's ticks. On a 60Hz display those arrive every ~16.7ms, so a
|
|
8
|
+
* strict `elapsed >= 33.3` test rejects the frame at 33.3ms by a hair and waits for the next one at
|
|
9
|
+
* 50ms — 20fps, not the 30 that was asked for. A few milliseconds of slack lets the frame that is
|
|
10
|
+
* near enough through, and the cap lands where it was aimed.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Frames a capped loop is allowed per second. */
|
|
14
|
+
export const CAPPED_FPS = 30;
|
|
15
|
+
/** Slack for rAF landing on display ticks — see the file note. */
|
|
16
|
+
export const FRAME_TOLERANCE_MS = 4;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A frame gate. Not a clock and not a scheduler: it answers one question and remembers the last time
|
|
20
|
+
* the answer was yes.
|
|
21
|
+
*
|
|
22
|
+
* @param {{ fps?: number, toleranceMs?: number }} [options]
|
|
23
|
+
* @returns {{ shouldRender(nowMs: number): boolean, reset(): void }}
|
|
24
|
+
*/
|
|
25
|
+
export function createFrameBudget({ fps = CAPPED_FPS, toleranceMs = FRAME_TOLERANCE_MS } = {}) {
|
|
26
|
+
const intervalMs = 1000 / (Number.isFinite(fps) && fps > 0 ? fps : CAPPED_FPS);
|
|
27
|
+
const minElapsedMs = Math.max(0, intervalMs - toleranceMs);
|
|
28
|
+
let lastAtMs = null;
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
/**
|
|
32
|
+
* True when `nowMs` is far enough past the last accepted frame. Accepting records the time, so
|
|
33
|
+
* the caller must only ask when it intends to act on the answer.
|
|
34
|
+
*/
|
|
35
|
+
shouldRender(nowMs) {
|
|
36
|
+
if (!Number.isFinite(nowMs)) return true;
|
|
37
|
+
if (lastAtMs !== null && nowMs - lastAtMs < minElapsedMs) return false;
|
|
38
|
+
lastAtMs = nowMs;
|
|
39
|
+
return true;
|
|
40
|
+
},
|
|
41
|
+
/** Forget the last frame, so the next question is answered as if none had been drawn. */
|
|
42
|
+
reset() {
|
|
43
|
+
lastAtMs = null;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
package/src/render/index.js
CHANGED
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
* @see `devHandleKey` — the one window global it can write, opt-in.
|
|
47
47
|
*/
|
|
48
48
|
import * as THREE from 'three';
|
|
49
|
+
import { createFrameBudget } from './frameBudget.js';
|
|
49
50
|
|
|
50
51
|
const GRID_ID = 'multi-orbiter-grid';
|
|
51
52
|
|
|
@@ -73,6 +74,10 @@ const CULL_SLOP_PX = 256;
|
|
|
73
74
|
* DOM positions (cells inside scrolling content). See the module doc.
|
|
74
75
|
* @param {Partial<typeof DEFAULT_RENDERER_PROFILE>} [opts.rendererProfile] renderer construction +
|
|
75
76
|
* pixel-ratio cap. Omit for the historical defaults.
|
|
77
|
+
* @param {boolean} [opts.performanceMode=false] start with the realm's renderer savings on — pixel
|
|
78
|
+
* ratio clamped to 1, shadow map off, this loop capped at 30fps. Off by default, and off means the
|
|
79
|
+
* renderer and the cadence are exactly what they were before this option existed. Toggle it live
|
|
80
|
+
* with `setPerformanceMode`.
|
|
76
81
|
* @param {string|null} [opts.devHandleKey=null] when set, publish the returned api on
|
|
77
82
|
* `window[devHandleKey]` so tooling can force a composited frame while rAF is throttled (a hidden
|
|
78
83
|
* tab during automated checks). Off by default — a library does not name globals for its host.
|
|
@@ -83,6 +88,7 @@ const CULL_SLOP_PX = 256;
|
|
|
83
88
|
* addVoice: (voice: { voiceId: string, cell: HTMLElement, controller: object }) => void,
|
|
84
89
|
* removeVoice: (voiceId: string) => void,
|
|
85
90
|
* renderOnce: () => void,
|
|
91
|
+
* setPerformanceMode: (enabled: boolean) => boolean,
|
|
86
92
|
* dispose: () => void,
|
|
87
93
|
* }}
|
|
88
94
|
*/
|
|
@@ -91,8 +97,21 @@ export function createViewportCompositor({
|
|
|
91
97
|
presentation = 'fixed-overlay',
|
|
92
98
|
rendererProfile = null,
|
|
93
99
|
devHandleKey = null,
|
|
100
|
+
performanceMode = false,
|
|
94
101
|
} = {}) {
|
|
95
102
|
const profile = { ...DEFAULT_RENDERER_PROFILE, ...(rendererProfile || {}) };
|
|
103
|
+
// The realm's renderer and the realm's loop are this module's, and they are the only things a
|
|
104
|
+
// single voice cannot change for itself: pixel ratio, shadow map and frame rate are one per realm,
|
|
105
|
+
// so a voice reaching for them would be deciding for its siblings. When the host says the realm is
|
|
106
|
+
// in performance mode, this is where that decision is made once, for all of them.
|
|
107
|
+
//
|
|
108
|
+
// Off is the default and off means untouched — a host that never mentions it (the feed embeds)
|
|
109
|
+
// gets exactly the renderer and the cadence it got before this existed.
|
|
110
|
+
let performanceOn = Boolean(performanceMode);
|
|
111
|
+
// What the realm's shadow map was before performance mode touched it, so turning the mode off puts
|
|
112
|
+
// the host's own choice back rather than asserting a default over it.
|
|
113
|
+
let baseShadowMapEnabled = null;
|
|
114
|
+
const frameBudget = createFrameBudget();
|
|
96
115
|
const perCell = presentation === 'per-cell';
|
|
97
116
|
// The one WebGL canvas. Fixed-overlay: full-bleed behind the grid, it owns the pixels for ALL
|
|
98
117
|
// voices. Per-cell: never enters the DOM — a render source the per-cell canvases copy from.
|
|
@@ -107,7 +126,30 @@ export function createViewportCompositor({
|
|
|
107
126
|
document.body.appendChild(canvas);
|
|
108
127
|
}
|
|
109
128
|
|
|
110
|
-
|
|
129
|
+
/**
|
|
130
|
+
* The renderer settings actually in force. Performance mode overrides the host's profile rather
|
|
131
|
+
* than replacing it, so whatever the host asked for comes back untouched when the mode goes off.
|
|
132
|
+
*
|
|
133
|
+
* `antialias` is deliberately not in here: it is fixed when the WebGL context is created, so
|
|
134
|
+
* turning it off would cost the host its antialiasing for the rest of the session the moment the
|
|
135
|
+
* mode was switched back on. The saving that IS live is the pixel ratio, and at dpr 2 that is
|
|
136
|
+
* three quarters of the fragments.
|
|
137
|
+
*/
|
|
138
|
+
function effectiveProfile() {
|
|
139
|
+
return performanceOn ? { ...profile, maxDevicePixelRatio: 1 } : profile;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Re-assert the realm-wide knobs. Called at construction and whenever the mode changes. */
|
|
143
|
+
function applyRealmRendererProfile() {
|
|
144
|
+
applyPixelRatio(renderer, effectiveProfile());
|
|
145
|
+
renderer.shadowMap.enabled = performanceOn ? false : baseShadowMapEnabled;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const renderer = createRenderer(canvas, effectiveProfile());
|
|
149
|
+
// Read before the mode has touched anything, so leaving performance mode restores the host's own
|
|
150
|
+
// setting instead of asserting three's default over it.
|
|
151
|
+
baseShadowMapEnabled = renderer.shadowMap.enabled;
|
|
152
|
+
if (performanceOn) renderer.shadowMap.enabled = false;
|
|
111
153
|
|
|
112
154
|
/** voiceId → { cell, controller, lastW, lastH, rect, blit } — `rect` is the GL draw rect
|
|
113
155
|
* (fixed-overlay) or the viewport-relative cull/size box (per-cell); `blit` is the per-cell
|
|
@@ -236,7 +278,9 @@ export function createViewportCompositor({
|
|
|
236
278
|
lastCanvasW = maxW;
|
|
237
279
|
lastCanvasH = maxH;
|
|
238
280
|
renderer.setSize(maxW, maxH, false);
|
|
239
|
-
|
|
281
|
+
// The EFFECTIVE profile, not the host's: a cell growing must not quietly hand the realm its
|
|
282
|
+
// full pixel ratio back while performance mode is still on.
|
|
283
|
+
applyPixelRatio(renderer, effectiveProfile());
|
|
240
284
|
}
|
|
241
285
|
rectsDirty = false;
|
|
242
286
|
return;
|
|
@@ -349,7 +393,10 @@ export function createViewportCompositor({
|
|
|
349
393
|
lastCanvasW = w;
|
|
350
394
|
lastCanvasH = h;
|
|
351
395
|
renderer.setSize(w, h, false);
|
|
352
|
-
|
|
396
|
+
// The EFFECTIVE profile, not the host's. A window resize — or dragging the window to a display
|
|
397
|
+
// with a different pixel ratio — must not restore full-resolution fragments while performance
|
|
398
|
+
// mode is still on, which is what re-applying the raw profile here would do.
|
|
399
|
+
applyPixelRatio(renderer, effectiveProfile());
|
|
353
400
|
}
|
|
354
401
|
|
|
355
402
|
/** Fixed-overlay frame: clear the displayed canvas, then scissor-render every visible voice at its
|
|
@@ -458,12 +505,36 @@ export function createViewportCompositor({
|
|
|
458
505
|
}
|
|
459
506
|
}
|
|
460
507
|
|
|
461
|
-
function tick() {
|
|
508
|
+
function tick(nowMs) {
|
|
462
509
|
if (disposed) return;
|
|
463
510
|
raf = requestAnimationFrame(tick);
|
|
511
|
+
// The cap belongs HERE, to the realm's own loop — never to a voice declining to draw inside
|
|
512
|
+
// `renderViewport`. A composited frame clears the whole canvas and then scissor-renders each cell
|
|
513
|
+
// into it, so a voice that skipped its draw would leave a black hole where its cell is and flash
|
|
514
|
+
// at half the realm's rate. Skipping the whole frame instead leaves every cell showing the last
|
|
515
|
+
// frame that was drawn — together, which is the only way a shared canvas can be consistent.
|
|
516
|
+
if (performanceOn && !frameBudget.shouldRender(nowMs)) return;
|
|
464
517
|
renderOnce();
|
|
465
518
|
}
|
|
466
519
|
|
|
520
|
+
/**
|
|
521
|
+
* Turn the realm's performance mode on or off, live. Clamps the realm pixel ratio to 1, turns the
|
|
522
|
+
* shadow map off and caps this loop at 30fps while on; puts all three back when off.
|
|
523
|
+
*
|
|
524
|
+
* @param {boolean} enabled
|
|
525
|
+
* @returns {boolean} whether the mode actually changed
|
|
526
|
+
*/
|
|
527
|
+
function setPerformanceMode(enabled) {
|
|
528
|
+
const next = Boolean(enabled);
|
|
529
|
+
if (disposed || next === performanceOn) return false;
|
|
530
|
+
performanceOn = next;
|
|
531
|
+
applyRealmRendererProfile();
|
|
532
|
+
// The next frame is the first of the new cadence — it must not be judged against a frame drawn
|
|
533
|
+
// under the old one.
|
|
534
|
+
frameBudget.reset();
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
|
|
467
538
|
function dispose() {
|
|
468
539
|
if (disposed) return;
|
|
469
540
|
disposed = true;
|
|
@@ -500,6 +571,7 @@ export function createViewportCompositor({
|
|
|
500
571
|
addVoice,
|
|
501
572
|
removeVoice,
|
|
502
573
|
renderOnce,
|
|
574
|
+
setPerformanceMode,
|
|
503
575
|
dispose,
|
|
504
576
|
};
|
|
505
577
|
// Dev handle: lets tooling force a composited frame when rAF is throttled (e.g. a hidden/background
|