@jgengine/core 0.24.0 → 0.26.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/CHANGELOG.md +92 -0
- package/dist/ai/rvo.d.ts +106 -0
- package/dist/ai/rvo.js +581 -0
- package/dist/ai/utilityBrain.d.ts +173 -0
- package/dist/ai/utilityBrain.js +277 -0
- package/dist/combat/cover.d.ts +169 -0
- package/dist/combat/cover.js +264 -0
- package/dist/combat/damageRuntime.d.ts +150 -0
- package/dist/combat/damageRuntime.js +208 -0
- package/dist/combat/damageTable.d.ts +116 -0
- package/dist/combat/damageTable.js +165 -0
- package/dist/combat/hitZones.d.ts +70 -0
- package/dist/combat/hitZones.js +77 -0
- package/dist/combat/magazine.d.ts +8 -0
- package/dist/combat/magazine.js +20 -2
- package/dist/combat/squadCombat.d.ts +167 -0
- package/dist/combat/squadCombat.js +557 -0
- package/dist/combat/squadTactics.d.ts +73 -0
- package/dist/combat/squadTactics.js +49 -0
- package/dist/combat/statusRuntime.d.ts +142 -0
- package/dist/combat/statusRuntime.js +236 -0
- package/dist/combat/weaponDefinition.d.ts +106 -0
- package/dist/combat/weaponDefinition.js +108 -0
- package/dist/combat/weaponSystem.d.ts +189 -0
- package/dist/combat/weaponSystem.js +469 -0
- package/dist/combat.d.ts +9 -0
- package/dist/combat.js +9 -0
- package/dist/devtools/devtools.d.ts +4 -2
- package/dist/devtools/devtools.js +2 -1
- package/dist/devtools/discover.d.ts +2 -1
- package/dist/devtools/discover.js +38 -0
- package/dist/devtools/frame.d.ts +0 -2
- package/dist/devtools/frame.js +1 -2
- package/dist/devtools/logBus.d.ts +58 -0
- package/dist/devtools/logBus.js +79 -0
- package/dist/devtools/logs.d.ts +6 -3
- package/dist/devtools/logs.js +17 -13
- package/dist/devtools/report.d.ts +120 -0
- package/dist/devtools/report.js +49 -0
- package/dist/devtools/reportEndpoint.d.ts +17 -0
- package/dist/devtools/reportEndpoint.js +7 -0
- package/dist/devtools/types.d.ts +48 -11
- package/dist/editor/document.js +210 -0
- package/dist/editor/environment.d.ts +48 -1
- package/dist/editor/environment.js +161 -0
- package/dist/editor/index.d.ts +6 -2
- package/dist/editor/index.js +1 -1
- package/dist/editor/types.d.ts +54 -0
- package/dist/game/notices.d.ts +4 -0
- package/dist/game/notices.js +4 -3
- package/dist/game/playableGame.d.ts +9 -1
- package/dist/gameplay.d.ts +4 -0
- package/dist/gameplay.js +4 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +78 -1
- package/dist/nav/navCrowd.d.ts +138 -0
- package/dist/nav/navCrowd.js +412 -0
- package/dist/nav/navRuntime.d.ts +108 -0
- package/dist/nav/navRuntime.js +256 -0
- package/dist/render/colorPipeline.d.ts +53 -0
- package/dist/render/colorPipeline.js +79 -0
- package/dist/render/lightProbes.d.ts +97 -0
- package/dist/render/lightProbes.js +221 -0
- package/dist/render/lookPreset.js +4 -4
- package/dist/render/physicalSky.d.ts +39 -0
- package/dist/render/physicalSky.js +89 -0
- package/dist/render/postProcessing.d.ts +40 -8
- package/dist/render/postProcessing.js +1 -1
- package/dist/render/skyOwnedLighting.d.ts +31 -0
- package/dist/render/skyOwnedLighting.js +46 -0
- package/dist/sensor/perception.d.ts +66 -5
- package/dist/sensor/perception.js +167 -42
- package/dist/sim/nearAgents.d.ts +68 -0
- package/dist/sim/nearAgents.js +152 -0
- package/dist/sim/needs.d.ts +60 -0
- package/dist/sim/needs.js +108 -0
- package/dist/sim/offScreen.d.ts +181 -0
- package/dist/sim/offScreen.js +450 -0
- package/dist/sim/routine.d.ts +79 -0
- package/dist/sim/routine.js +137 -0
- package/dist/visibility/visibilitySystem.js +21 -0
- package/dist/world/features.d.ts +8 -0
- package/dist/world.d.ts +5 -0
- package/dist/world.js +5 -0
- package/package.json +1 -1
- package/skills/jgengine/api.md +59 -6
- package/skills/jgengine/capabilities.md +4 -0
- package/skills/jgengine-combat/SKILL.md +17 -0
- package/skills/jgengine-combat/api.md +246 -0
- package/skills/jgengine-combat/capabilities.md +112 -0
- package/skills/jgengine-combat/reference.md +32 -0
- package/skills/jgengine-gameplay/SKILL.md +8 -0
- package/skills/jgengine-gameplay/api.md +71 -1
- package/skills/jgengine-gameplay/capabilities.md +16 -0
- package/skills/jgengine-world/SKILL.md +3 -1
- package/skills/jgengine-world/api.md +107 -3
- package/skills/jgengine-world/capabilities.md +22 -2
- package/skills/jgengine-world/recipes/npc-senses.md +8 -2
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { devtools } from "../devtools/devtools.js";
|
|
2
|
+
import { createNavMeshQuery, } from "./navMesh.js";
|
|
3
|
+
const DEFAULT_LINK_COSTS = { door: 0.5, ladder: 4, jump: 1.5, link: 0 };
|
|
4
|
+
const DEFAULT_SNAP_RADIUS = 2;
|
|
5
|
+
function finiteNumbers(value) {
|
|
6
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "number" && Number.isFinite(entry));
|
|
7
|
+
}
|
|
8
|
+
function isPolygonList(value, vertexCount) {
|
|
9
|
+
if (!Array.isArray(value))
|
|
10
|
+
return false;
|
|
11
|
+
return value.every((ring) => Array.isArray(ring) &&
|
|
12
|
+
ring.length >= 3 &&
|
|
13
|
+
ring.every((index) => typeof index === "number" && Number.isInteger(index) && index >= 0 && index < vertexCount));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Read the named navigation bake an editor wrote onto a scene document. Returns `null` when the
|
|
17
|
+
* document holds no such bake yet, and throws when the stored bake is not a valid nav mesh — a
|
|
18
|
+
* corrupt bake should fail at load, not as agents walking through walls.
|
|
19
|
+
*
|
|
20
|
+
* @capability navmesh-from-scene read the editor's baked nav mesh off a scene document
|
|
21
|
+
*/
|
|
22
|
+
export function navMeshFromBake(bakes, id = "main") {
|
|
23
|
+
const bake = bakes?.find((entry) => entry.kind === "nav" && entry.id === id);
|
|
24
|
+
if (bake === undefined)
|
|
25
|
+
return null;
|
|
26
|
+
if (bake.data === null || typeof bake.data !== "object")
|
|
27
|
+
throw new Error(`nav bake "${id}" has no data object`);
|
|
28
|
+
const data = bake.data;
|
|
29
|
+
if (!finiteNumbers(data.verts) || data.verts.length % 3 !== 0) {
|
|
30
|
+
throw new Error(`nav bake "${id}" verts must be finite xyz triples`);
|
|
31
|
+
}
|
|
32
|
+
const vertexCount = data.verts.length / 3;
|
|
33
|
+
if (!isPolygonList(data.polys, vertexCount))
|
|
34
|
+
throw new Error(`nav bake "${id}" polys must index its verts`);
|
|
35
|
+
if (data.links !== undefined && !Array.isArray(data.links))
|
|
36
|
+
throw new Error(`nav bake "${id}" links must be an array`);
|
|
37
|
+
const links = (data.links ?? []).map((link) => ({
|
|
38
|
+
from: link.from,
|
|
39
|
+
to: link.to,
|
|
40
|
+
...(link.cost === undefined ? {} : { cost: link.cost }),
|
|
41
|
+
...(link.start === undefined ? {} : { start: link.start }),
|
|
42
|
+
...(link.end === undefined ? {} : { end: link.end }),
|
|
43
|
+
}));
|
|
44
|
+
for (const link of links) {
|
|
45
|
+
if (!Number.isInteger(link.from) || !Number.isInteger(link.to))
|
|
46
|
+
throw new Error(`nav bake "${id}" links need polygon ids`);
|
|
47
|
+
}
|
|
48
|
+
const areas = data.areas;
|
|
49
|
+
const validAreas = finiteNumbers(areas) && areas.length === data.polys.length;
|
|
50
|
+
return { verts: [...data.verts], polys: data.polys.map((ring) => [...ring]), links, ...(validAreas ? { areas: [...areas] } : {}) };
|
|
51
|
+
}
|
|
52
|
+
function round(value) {
|
|
53
|
+
return Math.round(value * 1e4) / 1e4;
|
|
54
|
+
}
|
|
55
|
+
function vertexKey(x, y, z) {
|
|
56
|
+
return `${round(x)}|${round(y)}|${round(z)}`;
|
|
57
|
+
}
|
|
58
|
+
/** Order-independent handle for "the same polygon": its corner positions, rotated to a fixed start. */
|
|
59
|
+
function polySignature(mesh, ring) {
|
|
60
|
+
if (ring.length === 0)
|
|
61
|
+
return "";
|
|
62
|
+
const keys = [];
|
|
63
|
+
let best = 0;
|
|
64
|
+
for (let i = 0; i < ring.length; i += 1) {
|
|
65
|
+
const index = ring[i];
|
|
66
|
+
const key = vertexKey(mesh.verts[index * 3] ?? 0, mesh.verts[index * 3 + 1] ?? 0, mesh.verts[index * 3 + 2] ?? 0);
|
|
67
|
+
keys.push(key);
|
|
68
|
+
if (key < keys[best])
|
|
69
|
+
best = i;
|
|
70
|
+
}
|
|
71
|
+
return [...keys.slice(best), ...keys.slice(0, best)].join(";");
|
|
72
|
+
}
|
|
73
|
+
function linkCost(link, costs) {
|
|
74
|
+
if (link.cost !== undefined && Number.isFinite(link.cost) && link.cost >= 0)
|
|
75
|
+
return link.cost;
|
|
76
|
+
const kind = link.kind ?? "link";
|
|
77
|
+
const cost = costs[kind] ?? DEFAULT_LINK_COSTS[kind];
|
|
78
|
+
return Number.isFinite(cost) && cost >= 0 ? cost : DEFAULT_LINK_COSTS[kind];
|
|
79
|
+
}
|
|
80
|
+
function samePoint(a, b) {
|
|
81
|
+
if (a === undefined || b === undefined)
|
|
82
|
+
return a === b;
|
|
83
|
+
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
|
|
84
|
+
}
|
|
85
|
+
function sameLinks(a, b) {
|
|
86
|
+
if (a.length !== b.length)
|
|
87
|
+
return false;
|
|
88
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
89
|
+
const left = a[i];
|
|
90
|
+
const right = b[i];
|
|
91
|
+
if (left.from !== right.from || left.to !== right.to || left.cost !== right.cost)
|
|
92
|
+
return false;
|
|
93
|
+
if (!samePoint(left.start, right.start) || !samePoint(left.end, right.end))
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Route agents over the nav mesh an editor baked. Wraps {@link createNavMeshQuery}, resolves authored
|
|
100
|
+
* off-mesh links (doors, ladders, jumps) onto the current surface, and exchanges the mesh one scene
|
|
101
|
+
* region at a time so untouched polygons keep their ids and the agents routing over them keep their paths.
|
|
102
|
+
*
|
|
103
|
+
* @capability nav-runtime route agents on an editor-baked nav mesh with authored links and re-bakes that keep polygon identity
|
|
104
|
+
*/
|
|
105
|
+
export function createNavRuntime(options) {
|
|
106
|
+
let authored = (options.links ?? []).map((link) => ({ ...link }));
|
|
107
|
+
let linkCosts = { ...(options.linkCosts ?? {}) };
|
|
108
|
+
let snapRadius = Math.max(0, options.linkSnapRadius ?? DEFAULT_SNAP_RADIUS);
|
|
109
|
+
let resolved = [];
|
|
110
|
+
let unresolved = [];
|
|
111
|
+
let rebuildCount = 0;
|
|
112
|
+
let planCount = 0;
|
|
113
|
+
let lastPlanMs = 0;
|
|
114
|
+
let mesh = options.mesh;
|
|
115
|
+
const queryOptions = {
|
|
116
|
+
...(options.areaCosts === undefined ? {} : { areaCosts: { ...options.areaCosts } }),
|
|
117
|
+
...(options.maxNodes === undefined ? {} : { maxNodes: options.maxNodes }),
|
|
118
|
+
};
|
|
119
|
+
let query = createNavMeshQuery(mesh, queryOptions);
|
|
120
|
+
function resolveLinks() {
|
|
121
|
+
const base = mesh;
|
|
122
|
+
const out = [];
|
|
123
|
+
const missing = [];
|
|
124
|
+
const seen = new Set();
|
|
125
|
+
for (const link of authored) {
|
|
126
|
+
const from = query.findPolygon(link.from);
|
|
127
|
+
const to = query.findPolygon(link.to);
|
|
128
|
+
if (from === null || to === null || from === to) {
|
|
129
|
+
missing.push(link.id);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const startSurface = query.closestPoint(link.from);
|
|
133
|
+
const endSurface = query.closestPoint(link.to);
|
|
134
|
+
if (startSurface === null || endSurface === null) {
|
|
135
|
+
missing.push(link.id);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const startGap = Math.hypot(link.from[0] - startSurface[0], link.from[1] - startSurface[1], link.from[2] - startSurface[2]);
|
|
139
|
+
const endGap = Math.hypot(link.to[0] - endSurface[0], link.to[1] - endSurface[1], link.to[2] - endSurface[2]);
|
|
140
|
+
if (startGap > snapRadius || endGap > snapRadius) {
|
|
141
|
+
missing.push(link.id);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const pair = `${from}>${to}`;
|
|
145
|
+
if (seen.has(pair))
|
|
146
|
+
continue;
|
|
147
|
+
seen.add(pair);
|
|
148
|
+
out.push({ from, to, cost: linkCost(link, linkCosts), start: link.from, end: link.to });
|
|
149
|
+
}
|
|
150
|
+
resolved = out;
|
|
151
|
+
unresolved = missing;
|
|
152
|
+
if (!sameLinks(base.links, out))
|
|
153
|
+
mesh = { ...base, links: out };
|
|
154
|
+
query = createNavMeshQuery(mesh, query.snapshot());
|
|
155
|
+
}
|
|
156
|
+
function probeLine() {
|
|
157
|
+
const partial = planCount === 0 ? "no plans" : `${lastPlanMs.toFixed(2)} ms last`;
|
|
158
|
+
return `${mesh.polys.length} polys ${resolved.length} links ${unresolved.length} unresolved ${rebuildCount} rebuilds ${planCount} plans ${partial}`;
|
|
159
|
+
}
|
|
160
|
+
const unregister = options.devtoolsName === undefined ? null : devtools.probes.register(options.devtoolsName, () => probeLine());
|
|
161
|
+
resolveLinks();
|
|
162
|
+
return {
|
|
163
|
+
get mesh() {
|
|
164
|
+
return mesh;
|
|
165
|
+
},
|
|
166
|
+
plan(from, to) {
|
|
167
|
+
const started = performance.now();
|
|
168
|
+
const path = query.findPath(from, to);
|
|
169
|
+
planCount += 1;
|
|
170
|
+
lastPlanMs = performance.now() - started;
|
|
171
|
+
return path;
|
|
172
|
+
},
|
|
173
|
+
findPolygon: (point) => query.findPolygon(point),
|
|
174
|
+
closestPoint: (point) => query.closestPoint(point),
|
|
175
|
+
raycast: (from, to) => query.raycast(from, to),
|
|
176
|
+
applyMesh(next) {
|
|
177
|
+
const previous = mesh;
|
|
178
|
+
const remap = new Int32Array(previous.polys.length).fill(-1);
|
|
179
|
+
const swapped = {
|
|
180
|
+
verts: [...next.verts],
|
|
181
|
+
polys: next.polys.map((ring) => [...ring]),
|
|
182
|
+
links: [],
|
|
183
|
+
...(next.areas === undefined ? {} : { areas: [...next.areas] }),
|
|
184
|
+
};
|
|
185
|
+
// Polygon identity is matched on geometry, not array order: a bake numbers its polygons from
|
|
186
|
+
// scratch, so the only stable handle a route can hold is where the polygon actually is.
|
|
187
|
+
const bySignature = new Map();
|
|
188
|
+
for (let p = 0; p < swapped.polys.length; p += 1) {
|
|
189
|
+
const key = polySignature(swapped, swapped.polys[p]);
|
|
190
|
+
if (!bySignature.has(key))
|
|
191
|
+
bySignature.set(key, p);
|
|
192
|
+
}
|
|
193
|
+
let kept = 0;
|
|
194
|
+
let removed = 0;
|
|
195
|
+
for (let p = 0; p < previous.polys.length; p += 1) {
|
|
196
|
+
const match = bySignature.get(polySignature(previous, previous.polys[p]));
|
|
197
|
+
if (match === undefined)
|
|
198
|
+
removed += 1;
|
|
199
|
+
else {
|
|
200
|
+
remap[p] = match;
|
|
201
|
+
kept += 1;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
mesh = swapped;
|
|
205
|
+
rebuildCount += 1;
|
|
206
|
+
resolveLinks();
|
|
207
|
+
return { added: Math.max(0, swapped.polys.length - kept), removed, kept, remap };
|
|
208
|
+
},
|
|
209
|
+
links: () => resolved,
|
|
210
|
+
unresolvedLinks: () => unresolved,
|
|
211
|
+
rebuilds: () => rebuildCount,
|
|
212
|
+
retune(patch) {
|
|
213
|
+
if (patch.areaCosts !== undefined || patch.maxNodes !== undefined) {
|
|
214
|
+
query.retune({
|
|
215
|
+
...(patch.areaCosts === undefined ? {} : { areaCosts: { ...patch.areaCosts } }),
|
|
216
|
+
...(patch.maxNodes === undefined ? {} : { maxNodes: patch.maxNodes }),
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
let relink = false;
|
|
220
|
+
if (patch.links !== undefined) {
|
|
221
|
+
authored = patch.links.map((link) => ({ ...link }));
|
|
222
|
+
relink = true;
|
|
223
|
+
}
|
|
224
|
+
if (patch.linkCosts !== undefined) {
|
|
225
|
+
linkCosts = { ...patch.linkCosts };
|
|
226
|
+
relink = true;
|
|
227
|
+
}
|
|
228
|
+
if (patch.linkSnapRadius !== undefined) {
|
|
229
|
+
snapRadius = Math.max(0, patch.linkSnapRadius);
|
|
230
|
+
relink = true;
|
|
231
|
+
}
|
|
232
|
+
if (relink)
|
|
233
|
+
resolveLinks();
|
|
234
|
+
},
|
|
235
|
+
snapshot() {
|
|
236
|
+
const tuning = query.snapshot();
|
|
237
|
+
return {
|
|
238
|
+
areaCosts: { ...tuning.areaCosts },
|
|
239
|
+
maxNodes: tuning.maxNodes,
|
|
240
|
+
links: authored.map((link) => ({ ...link })),
|
|
241
|
+
linkCosts: { ...linkCosts },
|
|
242
|
+
linkSnapRadius: snapRadius,
|
|
243
|
+
};
|
|
244
|
+
},
|
|
245
|
+
restore(next) {
|
|
246
|
+
authored = next.links.map((link) => ({ ...link }));
|
|
247
|
+
linkCosts = { ...next.linkCosts };
|
|
248
|
+
snapRadius = Math.max(0, next.linkSnapRadius);
|
|
249
|
+
query.restore({ areaCosts: { ...next.areaCosts }, maxNodes: next.maxNodes });
|
|
250
|
+
resolveLinks();
|
|
251
|
+
},
|
|
252
|
+
dispose() {
|
|
253
|
+
unregister?.();
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calibration of the output pipeline, in linear scene luminance (1.0 = a white-looking surface at
|
|
3
|
+
* exposure 1).
|
|
4
|
+
*
|
|
5
|
+
* `WHITE_POINT` is the luminance the pipeline prints as display white under AgX. Everything above it
|
|
6
|
+
* is highlight headroom the tone curve rolls off, so a material meant to read as white is authored at
|
|
7
|
+
* 1.0 rather than at whatever the sun happened to be set to.
|
|
8
|
+
* `MID_GREY` is an 18% grey card — the metering target. Clamped auto-exposure drives the frame's key
|
|
9
|
+
* luminance here, which is what makes one material read the same in a street at noon and a lamp-lit
|
|
10
|
+
* room. There is one white point and one metering target; a second copy anywhere would make a grade
|
|
11
|
+
* depend on which stage read it.
|
|
12
|
+
*/
|
|
13
|
+
export declare const WHITE_POINT = 1;
|
|
14
|
+
/** Linear scene luminance of an 18% grey card — the auto-exposure metering target. */
|
|
15
|
+
export declare const MID_GREY = 0.18;
|
|
16
|
+
/**
|
|
17
|
+
* Pixels count toward metering up to this multiple of {@link WHITE_POINT}. A window or a sun disc sits
|
|
18
|
+
* far above it and would otherwise pull the whole frame down to a silhouette.
|
|
19
|
+
*/
|
|
20
|
+
export declare const METERING_HIGHLIGHT_CEILING: number;
|
|
21
|
+
/** Bounds and adaptation policy for clamped auto-exposure. */
|
|
22
|
+
export interface AutoExposureConfig {
|
|
23
|
+
/** Lowest exposure multiplier the meter may reach. Default 0.6 (0.7 stops down). */
|
|
24
|
+
min?: number;
|
|
25
|
+
/** Highest exposure multiplier the meter may reach. Default 2.5 (1.3 stops up). */
|
|
26
|
+
max?: number;
|
|
27
|
+
/** Seconds for the meter to cover ~63% of a gap to a new key luminance. Default 0.6. */
|
|
28
|
+
adaptationSeconds?: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Default clamped auto-exposure. The range is deliberately asymmetric: a bright street is already
|
|
32
|
+
* readable after AgX's shoulder, while a lamp-lit interior is the case that crushes, so the meter may
|
|
33
|
+
* lift 1.3 stops but only darken 0.7 — an artist's exposure survives.
|
|
34
|
+
*/
|
|
35
|
+
export declare const DEFAULT_AUTO_EXPOSURE: Readonly<Required<AutoExposureConfig>>;
|
|
36
|
+
/**
|
|
37
|
+
* Exposure that places a frame whose key luminance is `keyLuminance` on {@link MID_GREY}, bounded by
|
|
38
|
+
* the authored range. A dark interior asks for more than `max` and gets `max`; a blown-out exterior
|
|
39
|
+
* asks for less than `min` and gets `min`, so the frame never runs away at either end.
|
|
40
|
+
*/
|
|
41
|
+
export declare function exposureForKey(keyLuminance: number, config?: AutoExposureConfig): number;
|
|
42
|
+
/**
|
|
43
|
+
* Frame-rate independent blend of `current` toward `target`, exponential in time so a hitch does not
|
|
44
|
+
* overshoot and a steady view settles. `deltaSeconds` may be 0..∞; a zero-length step keeps `current`.
|
|
45
|
+
*/
|
|
46
|
+
export declare function adaptExposure(current: number, target: number, deltaSeconds: number, adaptationSeconds?: number): number;
|
|
47
|
+
/**
|
|
48
|
+
* Key luminance of a metered frame: the median of its samples, each clamped to
|
|
49
|
+
* {@link METERING_HIGHLIGHT_CEILING}. A median rather than a mean because a bright sky, a window or a
|
|
50
|
+
* lamp is a large minority of the frame: averaging those in drags an interior down to a silhouette,
|
|
51
|
+
* where the median still reports the room.
|
|
52
|
+
*/
|
|
53
|
+
export declare function keyLuminanceFromSamples(samples: ArrayLike<number>): number;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calibration of the output pipeline, in linear scene luminance (1.0 = a white-looking surface at
|
|
3
|
+
* exposure 1).
|
|
4
|
+
*
|
|
5
|
+
* `WHITE_POINT` is the luminance the pipeline prints as display white under AgX. Everything above it
|
|
6
|
+
* is highlight headroom the tone curve rolls off, so a material meant to read as white is authored at
|
|
7
|
+
* 1.0 rather than at whatever the sun happened to be set to.
|
|
8
|
+
* `MID_GREY` is an 18% grey card — the metering target. Clamped auto-exposure drives the frame's key
|
|
9
|
+
* luminance here, which is what makes one material read the same in a street at noon and a lamp-lit
|
|
10
|
+
* room. There is one white point and one metering target; a second copy anywhere would make a grade
|
|
11
|
+
* depend on which stage read it.
|
|
12
|
+
*/
|
|
13
|
+
export const WHITE_POINT = 1;
|
|
14
|
+
/** Linear scene luminance of an 18% grey card — the auto-exposure metering target. */
|
|
15
|
+
export const MID_GREY = 0.18;
|
|
16
|
+
/**
|
|
17
|
+
* Pixels count toward metering up to this multiple of {@link WHITE_POINT}. A window or a sun disc sits
|
|
18
|
+
* far above it and would otherwise pull the whole frame down to a silhouette.
|
|
19
|
+
*/
|
|
20
|
+
export const METERING_HIGHLIGHT_CEILING = WHITE_POINT * 8;
|
|
21
|
+
/**
|
|
22
|
+
* Default clamped auto-exposure. The range is deliberately asymmetric: a bright street is already
|
|
23
|
+
* readable after AgX's shoulder, while a lamp-lit interior is the case that crushes, so the meter may
|
|
24
|
+
* lift 1.3 stops but only darken 0.7 — an artist's exposure survives.
|
|
25
|
+
*/
|
|
26
|
+
export const DEFAULT_AUTO_EXPOSURE = {
|
|
27
|
+
min: 0.6,
|
|
28
|
+
max: 2.5,
|
|
29
|
+
adaptationSeconds: 0.6,
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Exposure that places a frame whose key luminance is `keyLuminance` on {@link MID_GREY}, bounded by
|
|
33
|
+
* the authored range. A dark interior asks for more than `max` and gets `max`; a blown-out exterior
|
|
34
|
+
* asks for less than `min` and gets `min`, so the frame never runs away at either end.
|
|
35
|
+
*/
|
|
36
|
+
export function exposureForKey(keyLuminance, config = {}) {
|
|
37
|
+
const min = config.min ?? DEFAULT_AUTO_EXPOSURE.min;
|
|
38
|
+
const max = config.max ?? DEFAULT_AUTO_EXPOSURE.max;
|
|
39
|
+
const low = Math.min(min, max);
|
|
40
|
+
const high = Math.max(min, max);
|
|
41
|
+
if (!Number.isFinite(keyLuminance) || keyLuminance <= 0)
|
|
42
|
+
return high;
|
|
43
|
+
return Math.min(high, Math.max(low, MID_GREY / keyLuminance));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Frame-rate independent blend of `current` toward `target`, exponential in time so a hitch does not
|
|
47
|
+
* overshoot and a steady view settles. `deltaSeconds` may be 0..∞; a zero-length step keeps `current`.
|
|
48
|
+
*/
|
|
49
|
+
export function adaptExposure(current, target, deltaSeconds, adaptationSeconds = DEFAULT_AUTO_EXPOSURE.adaptationSeconds) {
|
|
50
|
+
if (!Number.isFinite(target))
|
|
51
|
+
return current;
|
|
52
|
+
if (!(deltaSeconds > 0))
|
|
53
|
+
return current;
|
|
54
|
+
if (!(adaptationSeconds > 0))
|
|
55
|
+
return target;
|
|
56
|
+
const blend = 1 - Math.exp(-deltaSeconds / adaptationSeconds);
|
|
57
|
+
return current + (target - current) * blend;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Key luminance of a metered frame: the median of its samples, each clamped to
|
|
61
|
+
* {@link METERING_HIGHLIGHT_CEILING}. A median rather than a mean because a bright sky, a window or a
|
|
62
|
+
* lamp is a large minority of the frame: averaging those in drags an interior down to a silhouette,
|
|
63
|
+
* where the median still reports the room.
|
|
64
|
+
*/
|
|
65
|
+
export function keyLuminanceFromSamples(samples) {
|
|
66
|
+
if (samples.length === 0)
|
|
67
|
+
return MID_GREY;
|
|
68
|
+
const values = [];
|
|
69
|
+
for (let index = 0; index < samples.length; index += 1) {
|
|
70
|
+
const value = samples[index];
|
|
71
|
+
if (!(value > 0) || !Number.isFinite(value))
|
|
72
|
+
continue;
|
|
73
|
+
values.push(Math.min(value, METERING_HIGHLIGHT_CEILING));
|
|
74
|
+
}
|
|
75
|
+
if (values.length === 0)
|
|
76
|
+
return 0;
|
|
77
|
+
values.sort((a, b) => a - b);
|
|
78
|
+
return values[Math.floor((values.length - 1) / 2)];
|
|
79
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Baked irradiance volume: a grid of second-order (9-coefficient, RGB) spherical-harmonic probes
|
|
3
|
+
* over a box region, sampled at runtime for ambient lighting. It is the datum an interior has that
|
|
4
|
+
* a hemisphere light cannot: a room's fill is its own, not the sky's, and it varies across the space.
|
|
5
|
+
*
|
|
6
|
+
* Serializable and pure; the bake is deterministic heuristics (sky visibility, authored lamps and
|
|
7
|
+
* bounce colours), not a path tracer.
|
|
8
|
+
*/
|
|
9
|
+
/** SH coefficients per channel for the second band — 3 bands, real basis. */
|
|
10
|
+
export declare const SH_COEFFICIENTS = 9;
|
|
11
|
+
/** Floats stored per probe: {@link SH_COEFFICIENTS} × RGB. */
|
|
12
|
+
export declare const PROBE_STRIDE: number;
|
|
13
|
+
/** One irradiance volume authored on a scene document. */
|
|
14
|
+
export interface IrradianceVolume {
|
|
15
|
+
id: string;
|
|
16
|
+
/** World-space centre of the grid. */
|
|
17
|
+
center: readonly [number, number, number];
|
|
18
|
+
/** World-space extent of the grid. */
|
|
19
|
+
size: readonly [number, number, number];
|
|
20
|
+
/** Probe count per axis; at least 1. */
|
|
21
|
+
resolution: readonly [number, number, number];
|
|
22
|
+
/**
|
|
23
|
+
* `PROBE_STRIDE` floats per probe, row-major (x fastest, then y, then z). Absent until baked, so a
|
|
24
|
+
* freshly authored volume renders nothing rather than a fabricated ambient.
|
|
25
|
+
*/
|
|
26
|
+
samples?: readonly number[];
|
|
27
|
+
}
|
|
28
|
+
/** Authored bake inputs stored beside the samples so a volume can be re-baked without the editor. */
|
|
29
|
+
export interface IrradianceBakeSettings {
|
|
30
|
+
/** How much sky reaches the region: 0 fully enclosed, 1 fully open. */
|
|
31
|
+
skyVisibility: number;
|
|
32
|
+
/** Radiance above the horizon for an open region. Default the sky's own horizon colour. */
|
|
33
|
+
skyColor?: string;
|
|
34
|
+
/** Radiance below the horizon (ground bounce). */
|
|
35
|
+
groundColor?: string;
|
|
36
|
+
/** Fill colour of an enclosed region's ceiling; keeps a room from baking to black. */
|
|
37
|
+
ceilingColor?: string;
|
|
38
|
+
/** Fill colour of an enclosed region's floor. */
|
|
39
|
+
floorColor?: string;
|
|
40
|
+
/** Multiplier on the enclosed fill. Default 1. */
|
|
41
|
+
fillIntensity?: number;
|
|
42
|
+
/** Authored lamps folded into the bake. */
|
|
43
|
+
lights?: readonly {
|
|
44
|
+
position: readonly [number, number, number];
|
|
45
|
+
color?: string;
|
|
46
|
+
intensity?: number;
|
|
47
|
+
distance?: number;
|
|
48
|
+
}[];
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Real-basis SH values for a unit direction, into `out` (length 9):
|
|
52
|
+
* `[Y00, Y1-1, Y10, Y11, Y2-2, Y2-1, Y20, Y21, Y22]`.
|
|
53
|
+
* @internal
|
|
54
|
+
*/
|
|
55
|
+
export declare function shDirection(x: number, y: number, z: number, out: Float64Array | number[]): void;
|
|
56
|
+
/** World-space position of probe `(ix, iy, iz)`; the outer probes sit on the box faces.
|
|
57
|
+
* @internal
|
|
58
|
+
*/
|
|
59
|
+
export declare function probePosition(volume: Pick<IrradianceVolume, "center" | "size" | "resolution">, ix: number, iy: number, iz: number): [number, number, number];
|
|
60
|
+
/** Everything a bake needs for one volume: where the grid sits, how much sky reaches it, and what fills the dark. */
|
|
61
|
+
export interface IrradianceBakeInput {
|
|
62
|
+
/** Stable volume id, copied onto the baked volume. */
|
|
63
|
+
id: string;
|
|
64
|
+
center: readonly [number, number, number];
|
|
65
|
+
size: readonly [number, number, number];
|
|
66
|
+
resolution: readonly [number, number, number];
|
|
67
|
+
settings: IrradianceBakeSettings;
|
|
68
|
+
/** Direction samples per probe. Default 64. */
|
|
69
|
+
samples?: number;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Bakes an irradiance volume from authored inputs: sky radiance where the region is open, an
|
|
73
|
+
* authored ceiling/floor fill where it is enclosed, plus inverse-square lamp radiance. Each probe
|
|
74
|
+
* projects its directional radiance into L2 SH, so ambient varies across the grid and keeps a lit
|
|
75
|
+
* side instead of flattening to one number.
|
|
76
|
+
* @capability light-probes bake an irradiance volume from authored sky, fill and lamp inputs
|
|
77
|
+
*/
|
|
78
|
+
export declare function bakeIrradianceVolume(input: IrradianceBakeInput): IrradianceVolume;
|
|
79
|
+
/**
|
|
80
|
+
* The volume that governs a world position: the one whose box contains it, else the nearest by
|
|
81
|
+
* centre. A scene usually authors one volume per interior plus one for the exterior shell.
|
|
82
|
+
* @internal
|
|
83
|
+
*/
|
|
84
|
+
export declare function selectIrradianceVolume(volumes: readonly IrradianceVolume[], position: readonly [number, number, number]): IrradianceVolume | undefined;
|
|
85
|
+
/**
|
|
86
|
+
* Trilinearly samples the volume's SH at a world position, writing {@link PROBE_STRIDE} floats into
|
|
87
|
+
* `out`. Positions outside the grid clamp to its faces, so a probe volume never pops to black at its
|
|
88
|
+
* edge. Returns false when the volume has no baked samples.
|
|
89
|
+
* @internal
|
|
90
|
+
*/
|
|
91
|
+
export declare function sampleIrradianceVolume(volume: IrradianceVolume, position: readonly [number, number, number], out: Float32Array): boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Radiance along a direction from the SH, `Σ coeff·basis` clamped at zero. Used by tests and by
|
|
94
|
+
* tooling that previews a volume without a renderer.
|
|
95
|
+
* @internal
|
|
96
|
+
*/
|
|
97
|
+
export declare function radianceAlong(sh: ArrayLike<number>, normal: readonly [number, number, number]): [number, number, number];
|