@jgengine/shell 0.25.0 → 0.27.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 +109 -0
- package/dist/GamePlayerShell.d.ts +8 -0
- package/dist/GamePlayerShell.js +15 -1
- package/dist/Shell3dPresentation.js +29 -11
- package/dist/devtools/FrameTelemetry.d.ts +12 -0
- package/dist/devtools/FrameTelemetry.js +68 -0
- package/dist/devtools/agentBridge.js +2 -0
- package/dist/devtools/devtoolsReports.d.ts +2 -0
- package/dist/devtools/devtoolsReports.js +3 -0
- package/dist/devtools/frameSampler.d.ts +127 -0
- package/dist/devtools/frameSampler.js +345 -0
- package/dist/environment/AuthoredDaylight.d.ts +28 -0
- package/dist/environment/AuthoredDaylight.js +42 -0
- package/dist/environment/Daylight.d.ts +55 -7
- package/dist/environment/Daylight.js +126 -29
- package/dist/environment/EnvironmentScene.js +51 -8
- package/dist/environment/daylightCycle.d.ts +26 -4
- package/dist/environment/daylightCycle.js +36 -5
- package/dist/postfx/PostProcessing.d.ts +7 -1
- package/dist/postfx/PostProcessing.js +28 -2
- package/dist/postfx/autoExposure.d.ts +49 -0
- package/dist/postfx/autoExposure.js +189 -0
- package/dist/postfx/gradeShader.js +43 -4
- package/dist/presentationRecovery3d.js +2 -1
- package/dist/render/CascadedShadows.d.ts +3 -1
- package/dist/render/CascadedShadows.js +9 -1
- package/dist/render/EnvironmentLighting.d.ts +10 -1
- package/dist/render/EnvironmentLighting.js +11 -3
- package/dist/render/IrradianceVolumeLighting.d.ts +18 -0
- package/dist/render/IrradianceVolumeLighting.js +35 -0
- package/dist/render/SceneLighting.d.ts +14 -1
- package/dist/render/SceneLighting.js +29 -8
- package/dist/render/authoredGrade.d.ts +11 -0
- package/dist/render/authoredGrade.js +26 -0
- package/dist/render/decalGeometry.d.ts +26 -0
- package/dist/render/decalGeometry.js +54 -0
- package/dist/render/decalTexture.d.ts +13 -0
- package/dist/render/decalTexture.js +114 -0
- package/dist/render/modelWind.d.ts +28 -0
- package/dist/render/modelWind.js +103 -0
- package/dist/render/presentationLook.d.ts +15 -0
- package/dist/render/presentationLook.js +18 -0
- package/dist/render/stableTextureSet.d.ts +9 -0
- package/dist/render/stableTextureSet.js +24 -0
- package/dist/render/triplanarMaterial.d.ts +52 -0
- package/dist/render/triplanarMaterial.js +169 -0
- package/dist/render/useModelInstance.d.ts +1 -1
- package/dist/render/useModelInstance.js +3 -1
- package/dist/scatter/scatterModels.js +2 -0
- package/dist/scene/DecalRenderer.d.ts +2 -0
- package/dist/scene/DecalRenderer.js +74 -0
- package/dist/scene/GrassFieldRenderer.js +3 -2
- package/dist/scene/builtinSceneKindRenderers.js +2 -0
- package/dist/settings/appliedSettings.js +16 -13
- package/dist/terrain/GrassCover.d.ts +21 -0
- package/dist/terrain/GrassCover.js +84 -0
- package/dist/terrain/grassCoverGeometry.d.ts +40 -0
- package/dist/terrain/grassCoverGeometry.js +112 -0
- package/dist/terrain/terrainDetailMaterial.d.ts +12 -0
- package/dist/terrain/terrainDetailMaterial.js +54 -1
- package/dist/terrain/terrainSurfaces.d.ts +6 -0
- package/dist/terrain/terrainSurfaces.js +10 -0
- package/dist/world/WorldScene.js +2 -1
- package/package.json +4 -4
- package/skills/jgengine/api.md +59 -6
- package/skills/jgengine/capabilities.md +4 -0
- package/skills/jgengine-ui/api.md +114 -23
- package/skills/jgengine-ui/capabilities.md +14 -0
- package/skills/jgengine-ui/reference-primitives.md +4 -2
- package/skills/jgengine-world/SKILL.md +7 -1
- package/skills/jgengine-world/api.md +222 -8
- package/skills/jgengine-world/capabilities.md +42 -2
- package/skills/jgengine-world/recipes/npc-senses.md +8 -2
- package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { devtools } from "@jgengine/core/devtools/devtools";
|
|
2
|
+
import { roundReport, toReportLogs } from "@jgengine/core/devtools/report";
|
|
3
|
+
import { REPORT_ENDPOINT_PATH } from "@jgengine/core/devtools/reportEndpoint";
|
|
4
|
+
import { readUrlParam } from "@jgengine/core/devtools/urlFlags";
|
|
5
|
+
/**
|
|
6
|
+
* In-page frame telemetry: the game reports on itself instead of needing a second
|
|
7
|
+
* process to attach a profiler. Frame timings come from the devtools frame ring,
|
|
8
|
+
* renderer counts from the live renderer, GPU-object creation from wrapped GL calls,
|
|
9
|
+
* and per-system numbers from `devtools.probes` — all read on demand, so nothing
|
|
10
|
+
* here runs on the hot path.
|
|
11
|
+
*/
|
|
12
|
+
/** Query flag that turns the sampler off for an A/B frame-time comparison. */
|
|
13
|
+
export const TELEMETRY_FLAG = "telemetry";
|
|
14
|
+
/** How long after the sampler starts the GL counters get their post-warmup baseline. */
|
|
15
|
+
export const TELEMETRY_WARMUP_MS = 5_000;
|
|
16
|
+
/** Cadence of the push to the dev server, in milliseconds. */
|
|
17
|
+
export const REPORT_PUSH_MS = 1_000;
|
|
18
|
+
const WATCHED_GL_CALLS = [
|
|
19
|
+
["linkProgram", "programsLinked"],
|
|
20
|
+
["createFramebuffer", "framebuffers"],
|
|
21
|
+
["createBuffer", "buffers"],
|
|
22
|
+
["createTexture", "textures"],
|
|
23
|
+
["createVertexArray", "vertexArrays"],
|
|
24
|
+
];
|
|
25
|
+
function emptyGlTotals() {
|
|
26
|
+
return { programsLinked: 0, framebuffers: 0, buffers: 0, textures: 0, vertexArrays: 0 };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Wrap the live context's creation calls so a leak shows up as a non-zero counter.
|
|
30
|
+
* Only object creation is wrapped — never a draw call — so a steady-state frame
|
|
31
|
+
* pays nothing. Returns whether any call was actually wrapped, since a zero is only
|
|
32
|
+
* evidence when it was.
|
|
33
|
+
* @internal
|
|
34
|
+
*/
|
|
35
|
+
export function attachGlCounters(context) {
|
|
36
|
+
const totals = emptyGlTotals();
|
|
37
|
+
const target = context;
|
|
38
|
+
let attached = false;
|
|
39
|
+
for (const [method, key] of WATCHED_GL_CALLS) {
|
|
40
|
+
const original = target[method];
|
|
41
|
+
if (typeof original !== "function")
|
|
42
|
+
continue;
|
|
43
|
+
attached = true;
|
|
44
|
+
target[method] = (...args) => {
|
|
45
|
+
totals[key] += 1;
|
|
46
|
+
return original.apply(context, args);
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return { totals, attached };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Count live three.js nodes from the scene the game is drawing; `null` when it cannot be walked.
|
|
53
|
+
* @internal
|
|
54
|
+
*/
|
|
55
|
+
export function countSceneNodes(scene) {
|
|
56
|
+
const candidate = scene;
|
|
57
|
+
if (candidate === null || candidate === undefined || typeof candidate.traverse !== "function") {
|
|
58
|
+
return { nodes: null, scenes: 0 };
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
let nodes = 0;
|
|
62
|
+
candidate.traverse(() => {
|
|
63
|
+
nodes += 1;
|
|
64
|
+
});
|
|
65
|
+
return { nodes, scenes: 1 };
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return { nodes: null, scenes: 0 };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function glReport(inputs) {
|
|
72
|
+
const since = inputs.since;
|
|
73
|
+
return {
|
|
74
|
+
programsLinked: since.programsLinked,
|
|
75
|
+
framebuffers: since.framebuffers,
|
|
76
|
+
buffers: since.buffers,
|
|
77
|
+
textures: since.textures,
|
|
78
|
+
vertexArrays: since.vertexArrays,
|
|
79
|
+
allocations: since.programsLinked + since.framebuffers + since.buffers + since.textures + since.vertexArrays,
|
|
80
|
+
residentPrograms: inputs.residentPrograms,
|
|
81
|
+
total: { ...inputs.totals },
|
|
82
|
+
attached: inputs.attached,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Assemble the shared report shape from live sources. Pure: every number arrives as
|
|
87
|
+
* an argument, so a test can assert the shape without a renderer or a browser.
|
|
88
|
+
* @internal
|
|
89
|
+
*/
|
|
90
|
+
export function buildFrameReport(inputs) {
|
|
91
|
+
const frame = inputs.frame;
|
|
92
|
+
const scaled = frame === null
|
|
93
|
+
? { fps: 0, avgMs: 0, p95Ms: 0, maxMs: 0, samples: 0, longFrames: 0 }
|
|
94
|
+
: {
|
|
95
|
+
fps: roundReport(frame.fps, 1),
|
|
96
|
+
avgMs: roundReport(frame.avgFrameMs),
|
|
97
|
+
p95Ms: roundReport(frame.p95FrameMs),
|
|
98
|
+
maxMs: roundReport(frame.maxFrameMs),
|
|
99
|
+
samples: frame.samples,
|
|
100
|
+
longFrames: frame.longFrames,
|
|
101
|
+
};
|
|
102
|
+
const seconds = scaled.fps > 0 ? roundReport(scaled.samples / scaled.fps, 1) : 0;
|
|
103
|
+
return {
|
|
104
|
+
game: inputs.game,
|
|
105
|
+
gpu: inputs.gpu,
|
|
106
|
+
quality: inputs.quality,
|
|
107
|
+
viewport: inputs.viewport,
|
|
108
|
+
seconds,
|
|
109
|
+
at: inputs.at,
|
|
110
|
+
frame: scaled,
|
|
111
|
+
render: {
|
|
112
|
+
draws: inputs.render?.drawCalls ?? null,
|
|
113
|
+
triangles: inputs.render?.triangles ?? null,
|
|
114
|
+
geometries: inputs.render?.geometries ?? null,
|
|
115
|
+
textures: inputs.render?.textures ?? null,
|
|
116
|
+
nodes: inputs.scene.nodes,
|
|
117
|
+
nodesSource: inputs.scene.nodes === null ? "unavailable" : "scene-graph",
|
|
118
|
+
scenes: inputs.scene.scenes,
|
|
119
|
+
},
|
|
120
|
+
gl: glReport(inputs.gl),
|
|
121
|
+
sim: frame === null ? null : { avgMs: roundReport(frame.avgSimMs), outsideMs: roundReport(frame.avgOutsideMs) },
|
|
122
|
+
profile: null,
|
|
123
|
+
systems: inputs.systems,
|
|
124
|
+
logs: inputs.logs,
|
|
125
|
+
telemetry: {
|
|
126
|
+
source: "in-page",
|
|
127
|
+
warmedUp: inputs.warmedUp,
|
|
128
|
+
ageMs: Math.round(inputs.ageMs),
|
|
129
|
+
buildMs: roundReport(inputs.buildMs),
|
|
130
|
+
heapBytes: inputs.heapBytes,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function browserNow() {
|
|
135
|
+
const perf = globalThis.performance;
|
|
136
|
+
return perf !== undefined ? perf.now() : Date.now();
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* `Harbor Heat` -> `harbor-heat`, unless the dev runner's `?game=` says otherwise. The
|
|
140
|
+
* report is keyed by this, so it matches `bun run perf` and the budget file.
|
|
141
|
+
* @internal
|
|
142
|
+
*/
|
|
143
|
+
export function reportGameId(displayName) {
|
|
144
|
+
const fromRunner = readUrlParam("game");
|
|
145
|
+
if (fromRunner !== null && fromRunner.length > 0)
|
|
146
|
+
return fromRunner;
|
|
147
|
+
return displayName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Ask the dev server whether its report route exists. This is the sampler's only
|
|
151
|
+
* production guard and it is behaviour, not a build flag: the route is mounted by the
|
|
152
|
+
* dev Vite configs only, so a shipped game answers this one request and installs
|
|
153
|
+
* nothing — no timers, no GL wrapping, no `window.__JG`. The website dev server serves
|
|
154
|
+
* the /play runner as a production build, which is why a build-time `PROD` check would
|
|
155
|
+
* wrongly disable the report there.
|
|
156
|
+
* @internal
|
|
157
|
+
*/
|
|
158
|
+
export async function probeReportRoute(endpoint = REPORT_ENDPOINT_PATH, game = "", fetchImpl = typeof fetch === "function" ? fetch : null, timeoutMs = 1_500) {
|
|
159
|
+
if (fetchImpl === null)
|
|
160
|
+
return false;
|
|
161
|
+
try {
|
|
162
|
+
const response = await fetchImpl(`${endpoint}?game=${encodeURIComponent(game)}`, {
|
|
163
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
164
|
+
cache: "no-store",
|
|
165
|
+
});
|
|
166
|
+
const type = response.headers.get("content-type") ?? "";
|
|
167
|
+
if (!type.includes("application/json"))
|
|
168
|
+
return false;
|
|
169
|
+
const body = (await response.json());
|
|
170
|
+
return typeof body?.error === "string" || body?.frame !== undefined;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function heapUsedBytes() {
|
|
177
|
+
const memory = globalThis.performance?.memory;
|
|
178
|
+
return typeof memory?.usedJSHeapSize === "number" ? memory.usedJSHeapSize : null;
|
|
179
|
+
}
|
|
180
|
+
function subtract(totals, baseline) {
|
|
181
|
+
const since = emptyGlTotals();
|
|
182
|
+
for (const key of Object.keys(since)) {
|
|
183
|
+
since[key] = Math.max(0, totals[key] - (baseline?.[key] ?? 0));
|
|
184
|
+
}
|
|
185
|
+
return since;
|
|
186
|
+
}
|
|
187
|
+
let installed = null;
|
|
188
|
+
/**
|
|
189
|
+
* Reset the sampler's warmup baseline, so a fresh measurement starts from zero.
|
|
190
|
+
* @internal
|
|
191
|
+
*/
|
|
192
|
+
export function resetFrameTelemetry() {
|
|
193
|
+
installed?.resetWindow();
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Install the sampler for the page: publishes `window.__JG.report()` and pushes the
|
|
197
|
+
* same record to the dev server's report route every second. Returns `null` when
|
|
198
|
+
* `?telemetry=off` asks for the comparison run; callers gate installation on
|
|
199
|
+
* {@link probeReportRoute}, so a shipped game never starts the sampler.
|
|
200
|
+
* @internal
|
|
201
|
+
*/
|
|
202
|
+
export function installFrameTelemetry(sources) {
|
|
203
|
+
if ((readUrlParam(TELEMETRY_FLAG) ?? "").toLowerCase() === "off")
|
|
204
|
+
return null;
|
|
205
|
+
if (typeof window === "undefined")
|
|
206
|
+
return null;
|
|
207
|
+
const startedAt = browserNow();
|
|
208
|
+
let baseline = null;
|
|
209
|
+
let disposed = false;
|
|
210
|
+
let pushFailures = 0;
|
|
211
|
+
let scheduled = null;
|
|
212
|
+
const gameId = reportGameId(sources.game);
|
|
213
|
+
const endpoint = sources.endpoint ?? REPORT_ENDPOINT_PATH;
|
|
214
|
+
const shouldPush = sources.push !== false && typeof fetch === "function";
|
|
215
|
+
const markWarmup = () => {
|
|
216
|
+
const ageMs = browserNow() - startedAt;
|
|
217
|
+
if (baseline === null && ageMs >= TELEMETRY_WARMUP_MS)
|
|
218
|
+
baseline = { ...sources.gl().totals };
|
|
219
|
+
return baseline !== null;
|
|
220
|
+
};
|
|
221
|
+
const buildReport = () => {
|
|
222
|
+
const started = browserNow();
|
|
223
|
+
const warmedUp = markWarmup();
|
|
224
|
+
const gl = sources.gl();
|
|
225
|
+
const report = buildFrameReport({
|
|
226
|
+
game: gameId,
|
|
227
|
+
gpu: sources.gpu(),
|
|
228
|
+
quality: sources.quality(),
|
|
229
|
+
viewport: sources.viewport(),
|
|
230
|
+
at: Date.now(),
|
|
231
|
+
frame: devtools.frame.stats(),
|
|
232
|
+
render: sources.render(),
|
|
233
|
+
scene: countSceneNodes(sources.scene()),
|
|
234
|
+
gl: {
|
|
235
|
+
totals: gl.totals,
|
|
236
|
+
since: subtract(gl.totals, baseline),
|
|
237
|
+
residentPrograms: sources.residentPrograms(),
|
|
238
|
+
attached: gl.attached,
|
|
239
|
+
},
|
|
240
|
+
systems: devtools.probes.systems(),
|
|
241
|
+
logs: toReportLogs(devtools.logs.snapshot()),
|
|
242
|
+
heapBytes: sources.heapBytes?.() ?? heapUsedBytes(),
|
|
243
|
+
ageMs: browserNow() - startedAt,
|
|
244
|
+
warmedUp,
|
|
245
|
+
buildMs: browserNow() - started,
|
|
246
|
+
});
|
|
247
|
+
return report;
|
|
248
|
+
};
|
|
249
|
+
const telemetry = {
|
|
250
|
+
report: buildReport,
|
|
251
|
+
resetWindow() {
|
|
252
|
+
baseline = null;
|
|
253
|
+
devtools.frame.reset();
|
|
254
|
+
},
|
|
255
|
+
dispose() {
|
|
256
|
+
if (disposed)
|
|
257
|
+
return;
|
|
258
|
+
disposed = true;
|
|
259
|
+
clearInterval(timer);
|
|
260
|
+
if (scheduled !== null)
|
|
261
|
+
cancelIdle(scheduled);
|
|
262
|
+
window.removeEventListener("error", onError);
|
|
263
|
+
window.removeEventListener("unhandledrejection", onRejection);
|
|
264
|
+
document.removeEventListener("webglcontextlost", onContextLost, true);
|
|
265
|
+
const host = window;
|
|
266
|
+
if (host.__JG?.report === buildReport)
|
|
267
|
+
delete host.__JG;
|
|
268
|
+
if (installed === telemetry)
|
|
269
|
+
installed = null;
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
const pushOnce = async () => {
|
|
273
|
+
if (disposed)
|
|
274
|
+
return;
|
|
275
|
+
try {
|
|
276
|
+
const response = await fetch(endpoint, {
|
|
277
|
+
method: "POST",
|
|
278
|
+
headers: { "content-type": "application/json" },
|
|
279
|
+
body: JSON.stringify({ gameId, report: buildReport() }),
|
|
280
|
+
});
|
|
281
|
+
if (!response.ok)
|
|
282
|
+
throw new Error(`report route answered ${response.status}`);
|
|
283
|
+
pushFailures = 0;
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
pushFailures += 1;
|
|
287
|
+
if (pushFailures === 3) {
|
|
288
|
+
// A missing route (production build, or a host without the dev plugin) is not worth retrying forever.
|
|
289
|
+
clearInterval(timer);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
// Reports ride an idle callback so a build and its POST never land in a busy frame.
|
|
294
|
+
const run = () => {
|
|
295
|
+
scheduled = null;
|
|
296
|
+
void pushOnce();
|
|
297
|
+
};
|
|
298
|
+
const schedulePush = () => {
|
|
299
|
+
if (scheduled !== null || disposed)
|
|
300
|
+
return;
|
|
301
|
+
scheduled = scheduleIdle(run);
|
|
302
|
+
};
|
|
303
|
+
const timer = setInterval(schedulePush, REPORT_PUSH_MS);
|
|
304
|
+
const logError = (system, message) => {
|
|
305
|
+
devtools.logs.write("error", system, message);
|
|
306
|
+
// An error is the one line that must not wait for the next tick: a crash can tear
|
|
307
|
+
// the presentation down (or trigger a reload) before an idle callback ever runs.
|
|
308
|
+
if (shouldPush)
|
|
309
|
+
void pushOnce();
|
|
310
|
+
};
|
|
311
|
+
const onError = (event) => {
|
|
312
|
+
logError("runtime", `${event.message} (${event.filename ?? "?"}:${event.lineno ?? 0})`);
|
|
313
|
+
};
|
|
314
|
+
const onRejection = (event) => {
|
|
315
|
+
const reason = event.reason;
|
|
316
|
+
const message = reason instanceof Error ? `${reason.name}: ${reason.message}` : String(reason);
|
|
317
|
+
logError("runtime", `unhandled rejection: ${message}`);
|
|
318
|
+
};
|
|
319
|
+
const onContextLost = (event) => {
|
|
320
|
+
const target = event.target;
|
|
321
|
+
const where = target?.id !== undefined && target.id.length > 0 ? `#${target.id}` : "canvas";
|
|
322
|
+
logError("render", `webgl context lost on ${where}`);
|
|
323
|
+
};
|
|
324
|
+
window.addEventListener("error", onError);
|
|
325
|
+
window.addEventListener("unhandledrejection", onRejection);
|
|
326
|
+
document.addEventListener("webglcontextlost", onContextLost, true);
|
|
327
|
+
window.__JG = { report: buildReport };
|
|
328
|
+
installed = telemetry;
|
|
329
|
+
if (!shouldPush)
|
|
330
|
+
clearInterval(timer);
|
|
331
|
+
return telemetry;
|
|
332
|
+
}
|
|
333
|
+
function scheduleIdle(run) {
|
|
334
|
+
const idle = window;
|
|
335
|
+
if (typeof idle.requestIdleCallback === "function")
|
|
336
|
+
return idle.requestIdleCallback(run, { timeout: REPORT_PUSH_MS });
|
|
337
|
+
return window.setTimeout(run, 0);
|
|
338
|
+
}
|
|
339
|
+
function cancelIdle(handle) {
|
|
340
|
+
const idle = window;
|
|
341
|
+
if (typeof idle.cancelIdleCallback === "function")
|
|
342
|
+
idle.cancelIdleCallback(handle);
|
|
343
|
+
else
|
|
344
|
+
window.clearTimeout(handle);
|
|
345
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SkyEnvironmentDescriptor } from "@jgengine/core/world/features";
|
|
2
|
+
import type { DaylightModulation } from "../render/SceneLighting.js";
|
|
3
|
+
/** Props for {@link AuthoredDaylight}: the authored sky whose values are the noon keyframe, the world clock, and the holder configured lights read. */
|
|
4
|
+
export interface AuthoredDaylightProps {
|
|
5
|
+
/** The authored sky; its `sunIntensity`/`ambientIntensity` are the noon peak the cycle scales from. */
|
|
6
|
+
sky: SkyEnvironmentDescriptor;
|
|
7
|
+
/** The world's `SimClock` (or a stub exposing `calendar().dayFraction`). */
|
|
8
|
+
clock: {
|
|
9
|
+
calendar(): {
|
|
10
|
+
dayFraction: number;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
/** Mutable holder the shell's configured lights read every frame. */
|
|
14
|
+
modulation: DaylightModulation;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Drives authored `PlayableGame.lighting` from the day cycle: it scales the first directional (the
|
|
18
|
+
* sun) and the ambient fill between the authored noon peak and night, retints the sun, and mounts
|
|
19
|
+
* the moon. Without it a time-of-day sky only repaints the dome, so the ground keeps its midday key
|
|
20
|
+
* light after dark — a black sky over a sunlit city.
|
|
21
|
+
*
|
|
22
|
+
* The authored values stay the noon keyframe, mirroring how `horizonColor`/`zenithColor` already
|
|
23
|
+
* work, so art direction still owns how bright noon is.
|
|
24
|
+
*
|
|
25
|
+
* @capability day-night-sky let an authored sun set and a moon rise instead of a fixed midday key light
|
|
26
|
+
* @internal
|
|
27
|
+
*/
|
|
28
|
+
export declare function AuthoredDaylight({ sky, clock, modulation }: AuthoredDaylightProps): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useFrame } from "@react-three/fiber";
|
|
3
|
+
import { useMemo, useRef } from "react";
|
|
4
|
+
import * as THREE from "three";
|
|
5
|
+
import { MoonLight, useMoonAboveHorizon } from "./Daylight.js";
|
|
6
|
+
import { daylightStateAt, DEFAULT_DAY_AMBIENT_INTENSITY, DEFAULT_DAY_SUN_INTENSITY } from "./daylightCycle.js";
|
|
7
|
+
/**
|
|
8
|
+
* Drives authored `PlayableGame.lighting` from the day cycle: it scales the first directional (the
|
|
9
|
+
* sun) and the ambient fill between the authored noon peak and night, retints the sun, and mounts
|
|
10
|
+
* the moon. Without it a time-of-day sky only repaints the dome, so the ground keeps its midday key
|
|
11
|
+
* light after dark — a black sky over a sunlit city.
|
|
12
|
+
*
|
|
13
|
+
* The authored values stay the noon keyframe, mirroring how `horizonColor`/`zenithColor` already
|
|
14
|
+
* work, so art direction still owns how bright noon is.
|
|
15
|
+
*
|
|
16
|
+
* @capability day-night-sky let an authored sun set and a moon rise instead of a fixed midday key light
|
|
17
|
+
* @internal
|
|
18
|
+
*/
|
|
19
|
+
export function AuthoredDaylight({ sky, clock, modulation }) {
|
|
20
|
+
const initial = useMemo(() => daylightStateAt(clock.calendar().dayFraction, sky), [sky, clock]);
|
|
21
|
+
const noonSun = sky.sunIntensity ?? DEFAULT_DAY_SUN_INTENSITY;
|
|
22
|
+
const noonAmbient = sky.ambientIntensity ?? DEFAULT_DAY_AMBIENT_INTENSITY;
|
|
23
|
+
const moonRef = useRef(null);
|
|
24
|
+
const moonDirectionRef = useRef(initial.moonPosition);
|
|
25
|
+
const [moonUp, syncMoon] = useMoonAboveHorizon(initial.moonIntensity > 0);
|
|
26
|
+
useFrame(() => {
|
|
27
|
+
const state = daylightStateAt(clock.calendar().dayFraction, sky);
|
|
28
|
+
modulation.sunFactor = state.sunIntensity / noonSun;
|
|
29
|
+
modulation.sunColor = state.sunColor;
|
|
30
|
+
modulation.ambientFactor = state.ambientIntensity / noonAmbient;
|
|
31
|
+
moonDirectionRef.current = state.moonPosition;
|
|
32
|
+
const moon = moonRef.current;
|
|
33
|
+
if (moon !== null) {
|
|
34
|
+
moon.intensity = state.moonIntensity;
|
|
35
|
+
moon.color.set(state.moonColor);
|
|
36
|
+
}
|
|
37
|
+
syncMoon(state.moonIntensity);
|
|
38
|
+
});
|
|
39
|
+
if (!moonUp)
|
|
40
|
+
return null;
|
|
41
|
+
return (_jsx(MoonLight, { direction: initial.moonPosition, directionRef: moonDirectionRef, lightRef: moonRef, intensity: initial.moonIntensity, color: initial.moonColor }));
|
|
42
|
+
}
|
|
@@ -14,6 +14,14 @@ export interface SkyDomeProps {
|
|
|
14
14
|
sunColor?: string;
|
|
15
15
|
/** Sun disc/glow intensity multiplier. Default 1. */
|
|
16
16
|
sunIntensity?: number;
|
|
17
|
+
/** Direction toward the moon; a cool disc + halo is drawn there at night. Omit to skip the moon. */
|
|
18
|
+
moonDirection?: readonly [number, number, number];
|
|
19
|
+
/** Moon disc/halo colour. Default cool moonlight. */
|
|
20
|
+
moonColor?: string;
|
|
21
|
+
/** Moon disc/halo intensity multiplier. Default 0 (no moon). */
|
|
22
|
+
moonIntensity?: number;
|
|
23
|
+
/** Star-field opacity: 0 is a day sky, 1 is a clear night. Default 0. */
|
|
24
|
+
starOpacity?: number;
|
|
17
25
|
/** Horizon haze-band strength: 0 removes the dusty band, ~1 makes it heavy. Default 0.5. */
|
|
18
26
|
hazeStrength?: number;
|
|
19
27
|
/** Procedural cloud-band opacity: 0 is a clear sky, 1 is heavy overcast. Default 0.35. */
|
|
@@ -23,13 +31,29 @@ export interface SkyDomeProps {
|
|
|
23
31
|
/** Exposes the created shader material so a time-of-day driver can mutate its uniforms per frame without recreating it. */
|
|
24
32
|
materialRef?: MutableRefObject<THREE.ShaderMaterial | null>;
|
|
25
33
|
}
|
|
34
|
+
/** Aim the dome's moon disc and dial its brightness; called per frame by the time-of-day drivers. @internal */
|
|
35
|
+
export declare function setSkyMoon(material: THREE.ShaderMaterial, direction: readonly [number, number, number], color: THREE.ColorRepresentation, intensity: number, starOpacity: number): void;
|
|
26
36
|
/**
|
|
27
37
|
* Shadow-casting sun whose shadow box leads the camera each frame ({@link sunShadowFocus}), so
|
|
28
38
|
* grounded shadows stay crisp and soft-edged under the player anywhere in a large world. Time-of-day
|
|
29
39
|
* drivers aim it through `directionRef` and retint it through `lightRef`.
|
|
30
40
|
* @internal
|
|
31
41
|
*/
|
|
32
|
-
export declare function SunLight({ direction, directionRef, lightRef, intensity, color, }: {
|
|
42
|
+
export declare function SunLight({ direction, directionRef, lightRef, intensity, color, castShadow, }: {
|
|
43
|
+
direction: readonly [number, number, number];
|
|
44
|
+
directionRef?: MutableRefObject<readonly [number, number, number]>;
|
|
45
|
+
lightRef?: RefObject<THREE.DirectionalLight | null>;
|
|
46
|
+
intensity: number;
|
|
47
|
+
color: string;
|
|
48
|
+
/** Shadow-casting key light. Off for the moon: its light is too weak to read a shadow and a second shadow pass is not free. */
|
|
49
|
+
castShadow?: boolean;
|
|
50
|
+
}): import("react").JSX.Element;
|
|
51
|
+
/**
|
|
52
|
+
* The moon as a dim, shadowless key light opposite the sun, so a night scene keeps a lit and a
|
|
53
|
+
* shaded side instead of collapsing into flat ambient. Same camera-following aim as the sun.
|
|
54
|
+
* @internal
|
|
55
|
+
*/
|
|
56
|
+
export declare function MoonLight({ direction, directionRef, lightRef, intensity, color }: {
|
|
33
57
|
direction: readonly [number, number, number];
|
|
34
58
|
directionRef?: MutableRefObject<readonly [number, number, number]>;
|
|
35
59
|
lightRef?: RefObject<THREE.DirectionalLight | null>;
|
|
@@ -58,7 +82,7 @@ export declare function SkyFog({ color, near, far, fogRef }: {
|
|
|
58
82
|
/** Sets the display color a {@link SkyFog} converts each frame. @internal */
|
|
59
83
|
export declare function setSkyFogColor(fog: THREE.Fog, color: THREE.ColorRepresentation): void;
|
|
60
84
|
/** @internal */
|
|
61
|
-
export declare function SkyDome({ topColor, horizonColor, radius, offset, exponent, sunDirection, sunColor, sunIntensity, hazeStrength, sunGlowStrength, cloudiness, materialRef, }?: SkyDomeProps): import("react").JSX.Element;
|
|
85
|
+
export declare function SkyDome({ topColor, horizonColor, radius, offset, exponent, sunDirection, sunColor, sunIntensity, moonDirection, moonColor, moonIntensity, starOpacity, hazeStrength, sunGlowStrength, cloudiness, materialRef, }?: SkyDomeProps): import("react").JSX.Element;
|
|
62
86
|
export interface DaylightProps {
|
|
63
87
|
sky?: SkyDomeProps | false;
|
|
64
88
|
fog?: {
|
|
@@ -71,6 +95,12 @@ export interface DaylightProps {
|
|
|
71
95
|
intensity?: number;
|
|
72
96
|
color?: string;
|
|
73
97
|
};
|
|
98
|
+
moon?: {
|
|
99
|
+
position?: readonly [number, number, number];
|
|
100
|
+
intensity?: number;
|
|
101
|
+
color?: string;
|
|
102
|
+
stars?: number;
|
|
103
|
+
};
|
|
74
104
|
ambient?: {
|
|
75
105
|
skyColor?: string;
|
|
76
106
|
groundColor?: string;
|
|
@@ -82,17 +112,29 @@ export interface DaylightProps {
|
|
|
82
112
|
clouds?: VolumetricCloudsConfig;
|
|
83
113
|
}
|
|
84
114
|
/** @internal */
|
|
85
|
-
export declare function Daylight({ sky, fog, sun, ambient, lights, clouds }?: DaylightProps): import("react").JSX.Element;
|
|
115
|
+
export declare function Daylight({ sky, fog, sun, ambient, moon, lights, clouds }?: DaylightProps): import("react").JSX.Element;
|
|
86
116
|
export interface SkyDaylightProps {
|
|
87
117
|
sky: SkyEnvironmentDescriptor;
|
|
88
118
|
lights?: boolean;
|
|
89
|
-
/** Terrain `biomeBands`; when any carries `fog`/`sky`, fog and
|
|
119
|
+
/** Terrain `biomeBands`; when any carries `fog`/`sky`, fog and cross-fade per camera z. */
|
|
90
120
|
bands?: readonly BiomeBand[];
|
|
121
|
+
/** Fog range authored on the game's `backdrop.fog`, used when the sky descriptor sets none. */
|
|
122
|
+
fogRange?: {
|
|
123
|
+
near?: number;
|
|
124
|
+
far?: number;
|
|
125
|
+
};
|
|
91
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Mount state for the moon: it re-renders only when the moon crosses the horizon. A zero-intensity
|
|
129
|
+
* directional light still costs a per-fragment loop in every lit material, so a day sky must not
|
|
130
|
+
* carry it.
|
|
131
|
+
* @internal
|
|
132
|
+
*/
|
|
133
|
+
export declare function useMoonAboveHorizon(initialUp: boolean): [boolean, (intensity: number) => void];
|
|
92
134
|
/** Renders a fixed sky/sun/fog look sampled from `sky`'s preset (or, when `timeOfDay` is on but no clock drives it, its noon look). No per-frame updates.
|
|
93
135
|
* @internal
|
|
94
136
|
*/
|
|
95
|
-
export declare function SkyDaylight({ sky, lights, bands }: SkyDaylightProps): import("react").JSX.Element;
|
|
137
|
+
export declare function SkyDaylight({ sky, lights, bands, fogRange }: SkyDaylightProps): import("react").JSX.Element;
|
|
96
138
|
export interface TimeOfDayDaylightProps {
|
|
97
139
|
sky: SkyEnvironmentDescriptor;
|
|
98
140
|
/** The world's `SimClock` (or a stub exposing `calendar().dayFraction`). Absent means static rendering. */
|
|
@@ -104,11 +146,17 @@ export interface TimeOfDayDaylightProps {
|
|
|
104
146
|
lights?: boolean;
|
|
105
147
|
/** Terrain `biomeBands`; when any carries `fog`/`sky`, fog and dome cross-fade per camera z. */
|
|
106
148
|
bands?: readonly BiomeBand[];
|
|
149
|
+
/** Fog range authored on the game's `backdrop.fog`, used when the sky descriptor sets none. */
|
|
150
|
+
fogRange?: {
|
|
151
|
+
near?: number;
|
|
152
|
+
far?: number;
|
|
153
|
+
};
|
|
107
154
|
}
|
|
108
155
|
/**
|
|
109
156
|
* Drives sky/fog (and optional default lights) from the world clock when `sky.timeOfDay` and `clock`
|
|
110
157
|
* are both present. Authored `PlayableGame.lighting` is never rewritten — pass `lights={false}` so
|
|
111
|
-
* only dome colors and fog track the day fraction
|
|
158
|
+
* only dome colors and fog track the day fraction; `AuthoredDaylight` scales those authored lights
|
|
159
|
+
* from the same state.
|
|
112
160
|
* @internal
|
|
113
161
|
*/
|
|
114
|
-
export declare function TimeOfDayDaylight({ sky, clock, lights, bands }: TimeOfDayDaylightProps): import("react").JSX.Element;
|
|
162
|
+
export declare function TimeOfDayDaylight({ sky, clock, lights, bands, fogRange }: TimeOfDayDaylightProps): import("react").JSX.Element;
|