@vulfram/engine 0.14.8-alpha → 0.17.1-alpha

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.
Files changed (54) hide show
  1. package/README.md +106 -0
  2. package/package.json +55 -4
  3. package/src/core.ts +14 -0
  4. package/src/ecs.ts +1 -0
  5. package/src/engine/api.ts +234 -23
  6. package/src/engine/bridge/dispatch.ts +265 -40
  7. package/src/engine/bridge/guards.ts +4 -1
  8. package/src/engine/bridge/protocol.ts +69 -52
  9. package/src/engine/ecs/index.ts +185 -42
  10. package/src/engine/state.ts +133 -2
  11. package/src/engine/systems/command-intent.ts +153 -3
  12. package/src/engine/systems/constraint-solve.ts +167 -0
  13. package/src/engine/systems/core-command-builder.ts +9 -268
  14. package/src/engine/systems/diagnostics.ts +20 -19
  15. package/src/engine/systems/index.ts +3 -1
  16. package/src/engine/systems/input-mirror.ts +101 -3
  17. package/src/engine/systems/resource-upload.ts +96 -44
  18. package/src/engine/systems/response-decode.ts +69 -15
  19. package/src/engine/systems/scene-sync.ts +306 -0
  20. package/src/engine/systems/ui-bridge.ts +360 -0
  21. package/src/engine/systems/utils.ts +43 -1
  22. package/src/engine/systems/world-lifecycle.ts +37 -102
  23. package/src/engine/window/manager.ts +168 -0
  24. package/src/engine/world/entities.ts +821 -78
  25. package/src/engine/world/mount.ts +174 -0
  26. package/src/engine/world/types.ts +71 -0
  27. package/src/engine/world/world-ui.ts +266 -0
  28. package/src/engine/world/world3d.ts +280 -0
  29. package/src/index.ts +30 -1
  30. package/src/mount.ts +2 -0
  31. package/src/types/cmds/audio.ts +73 -48
  32. package/src/types/cmds/camera.ts +12 -8
  33. package/src/types/cmds/environment.ts +9 -3
  34. package/src/types/cmds/geometry.ts +13 -14
  35. package/src/types/cmds/index.ts +198 -168
  36. package/src/types/cmds/light.ts +12 -11
  37. package/src/types/cmds/material.ts +9 -11
  38. package/src/types/cmds/model.ts +17 -15
  39. package/src/types/cmds/realm.ts +25 -0
  40. package/src/types/cmds/system.ts +19 -0
  41. package/src/types/cmds/target.ts +82 -0
  42. package/src/types/cmds/texture.ts +13 -3
  43. package/src/types/cmds/ui.ts +220 -0
  44. package/src/types/cmds/window.ts +41 -204
  45. package/src/types/events/index.ts +4 -1
  46. package/src/types/events/pointer.ts +42 -13
  47. package/src/types/events/system.ts +144 -30
  48. package/src/types/events/ui.ts +21 -0
  49. package/src/types/index.ts +1 -0
  50. package/src/types/json.ts +15 -0
  51. package/src/window.ts +8 -0
  52. package/src/world-ui.ts +2 -0
  53. package/src/world3d.ts +10 -0
  54. package/tsconfig.json +0 -29
@@ -0,0 +1,360 @@
1
+ import { KeyCode } from '../../types/events/keyboard';
2
+ import { enqueueCommand } from '../bridge/dispatch';
3
+ import type {
4
+ InputStateComponent,
5
+ Intent,
6
+ System,
7
+ UiFieldsetScope,
8
+ UiFocusableNode,
9
+ UiFormScope,
10
+ UiStateComponent,
11
+ } from '../ecs';
12
+
13
+ const WORLD_ENTITY_ID = 0;
14
+
15
+ function ensureUiState(world: Parameters<System>[0]): UiStateComponent {
16
+ let worldStore = world.components.get(WORLD_ENTITY_ID);
17
+ if (!worldStore) {
18
+ worldStore = new Map();
19
+ world.components.set(WORLD_ENTITY_ID, worldStore);
20
+ world.entities.add(WORLD_ENTITY_ID);
21
+ }
22
+
23
+ let uiState = worldStore.get('UiState') as UiStateComponent | undefined;
24
+ if (!uiState) {
25
+ uiState = {
26
+ type: 'UiState',
27
+ forms: new Map(),
28
+ fieldsets: new Map(),
29
+ nodes: new Map(),
30
+ focusByWindow: new Map(),
31
+ };
32
+ worldStore.set('UiState', uiState);
33
+ }
34
+
35
+ return uiState;
36
+ }
37
+
38
+ function getInputState(world: Parameters<System>[0]): InputStateComponent | undefined {
39
+ const worldStore = world.components.get(WORLD_ENTITY_ID);
40
+ return worldStore?.get('InputState') as InputStateComponent | undefined;
41
+ }
42
+
43
+ function fieldsetKey(formId: string, fieldsetId: string): string {
44
+ return `${formId}::${fieldsetId}`;
45
+ }
46
+
47
+ function canFocusNode(
48
+ form: UiFormScope,
49
+ node: UiFocusableNode,
50
+ fieldsets: Map<string, UiFieldsetScope>,
51
+ ): boolean {
52
+ if (form.disabled || node.disabled || node.tabIndex < 0) {
53
+ return false;
54
+ }
55
+
56
+ if (form.activeFieldsetId && node.fieldsetId !== form.activeFieldsetId) {
57
+ return false;
58
+ }
59
+
60
+ if (node.fieldsetId) {
61
+ const fieldset = fieldsets.get(fieldsetKey(form.formId, node.fieldsetId));
62
+ if (fieldset?.disabled && fieldset.legendNodeId !== node.nodeId) {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ return true;
68
+ }
69
+
70
+ function sortFocusables(a: UiFocusableNode, b: UiFocusableNode): number {
71
+ const aPositive = a.tabIndex > 0;
72
+ const bPositive = b.tabIndex > 0;
73
+ if (aPositive !== bPositive) return aPositive ? -1 : 1;
74
+
75
+ if (aPositive && bPositive && a.tabIndex !== b.tabIndex) {
76
+ return a.tabIndex - b.tabIndex;
77
+ }
78
+
79
+ if (a.orderHint !== b.orderHint) {
80
+ return a.orderHint - b.orderHint;
81
+ }
82
+
83
+ return a.nodeId - b.nodeId;
84
+ }
85
+
86
+ function resolveTargetForm(
87
+ uiState: UiStateComponent,
88
+ windowId: number,
89
+ formId?: string,
90
+ ): UiFormScope | undefined {
91
+ if (formId) {
92
+ const form = uiState.forms.get(formId);
93
+ if (form && form.windowId === windowId && !form.disabled) {
94
+ return form;
95
+ }
96
+ }
97
+
98
+ const focused = uiState.focusByWindow.get(windowId);
99
+ if (focused) {
100
+ const form = uiState.forms.get(focused.formId);
101
+ if (form && !form.disabled) {
102
+ return form;
103
+ }
104
+ }
105
+
106
+ const forms = Array.from(uiState.forms.values())
107
+ .filter((f) => f.windowId === windowId && !f.disabled)
108
+ .sort((a, b) => a.formId.localeCompare(b.formId));
109
+
110
+ return forms[0];
111
+ }
112
+
113
+ function resolveNextNode(
114
+ uiState: UiStateComponent,
115
+ windowId: number,
116
+ backwards: boolean,
117
+ formId?: string,
118
+ ): { form: UiFormScope; nodeId: number } | null {
119
+ const form = resolveTargetForm(uiState, windowId, formId);
120
+ if (!form) return null;
121
+
122
+ const candidates = Array.from(uiState.nodes.values())
123
+ .filter((node) => node.formId === form.formId)
124
+ .filter((node) => canFocusNode(form, node, uiState.fieldsets))
125
+ .sort(sortFocusables);
126
+
127
+ if (candidates.length === 0) {
128
+ return null;
129
+ }
130
+
131
+ const focused = uiState.focusByWindow.get(windowId);
132
+ const activeNodeId =
133
+ focused?.formId === form.formId ? focused.nodeId : form.activeNodeId;
134
+ const currentIndex = candidates.findIndex((node) => node.nodeId === activeNodeId);
135
+
136
+ let nextIndex = currentIndex;
137
+ if (currentIndex < 0) {
138
+ nextIndex = backwards ? candidates.length - 1 : 0;
139
+ } else {
140
+ nextIndex = backwards ? currentIndex - 1 : currentIndex + 1;
141
+ }
142
+
143
+ const cycleMode = form.cycleMode;
144
+ if (nextIndex < 0 || nextIndex >= candidates.length) {
145
+ if (cycleMode === 'clamp') {
146
+ return null;
147
+ }
148
+ nextIndex = nextIndex < 0 ? candidates.length - 1 : 0;
149
+ }
150
+
151
+ const target = candidates[nextIndex];
152
+ if (!target) return null;
153
+ return { form, nodeId: target.nodeId };
154
+ }
155
+
156
+ function applyFocus(uiState: UiStateComponent, windowId: number, formId: string, nodeId: number): void {
157
+ uiState.focusByWindow.set(windowId, { formId, nodeId });
158
+ const form = uiState.forms.get(formId);
159
+ if (form) {
160
+ form.activeNodeId = nodeId;
161
+ }
162
+ }
163
+
164
+ function processUiIntent(
165
+ worldId: number,
166
+ uiState: UiStateComponent,
167
+ intent: Intent,
168
+ ): boolean {
169
+ switch (intent.type) {
170
+ case 'ui-theme-define':
171
+ enqueueCommand(worldId, 'cmd-ui-theme-define', intent.args);
172
+ return true;
173
+ case 'ui-theme-dispose':
174
+ enqueueCommand(worldId, 'cmd-ui-theme-dispose', intent.args);
175
+ return true;
176
+ case 'ui-document-create':
177
+ enqueueCommand(worldId, 'cmd-ui-document-create', intent.args);
178
+ return true;
179
+ case 'ui-document-dispose':
180
+ enqueueCommand(worldId, 'cmd-ui-document-dispose', intent.args);
181
+ return true;
182
+ case 'ui-document-set-rect':
183
+ enqueueCommand(worldId, 'cmd-ui-document-set-rect', intent.args);
184
+ return true;
185
+ case 'ui-document-set-theme':
186
+ enqueueCommand(worldId, 'cmd-ui-document-set-theme', intent.args);
187
+ return true;
188
+ case 'ui-document-get-tree':
189
+ enqueueCommand(worldId, 'cmd-ui-document-get-tree', intent.args);
190
+ return true;
191
+ case 'ui-document-get-layout-rects':
192
+ enqueueCommand(worldId, 'cmd-ui-document-get-layout-rects', intent.args);
193
+ return true;
194
+ case 'ui-apply-ops':
195
+ enqueueCommand(worldId, 'cmd-ui-apply-ops', intent.args);
196
+ return true;
197
+ case 'ui-debug-set':
198
+ enqueueCommand(worldId, 'cmd-ui-debug-set', intent.args);
199
+ return true;
200
+ case 'ui-focus-set': {
201
+ enqueueCommand(worldId, 'cmd-ui-focus-set', intent.args);
202
+ const form = resolveTargetForm(uiState, intent.args.windowId);
203
+ if (form && form.realmId === intent.args.realmId && form.documentId === intent.args.documentId) {
204
+ applyFocus(uiState, intent.args.windowId, form.formId, intent.args.nodeId ?? 0);
205
+ }
206
+ return true;
207
+ }
208
+ case 'ui-focus-get':
209
+ enqueueCommand(worldId, 'cmd-ui-focus-get', intent.args);
210
+ return true;
211
+ case 'ui-event-trace-set':
212
+ enqueueCommand(worldId, 'cmd-ui-event-trace-set', intent.args);
213
+ return true;
214
+ case 'ui-image-create-from-buffer':
215
+ enqueueCommand(worldId, 'cmd-ui-image-create-from-buffer', intent.args);
216
+ return true;
217
+ case 'ui-image-dispose':
218
+ enqueueCommand(worldId, 'cmd-ui-image-dispose', intent.args);
219
+ return true;
220
+ case 'ui-clipboard-paste':
221
+ enqueueCommand(worldId, 'cmd-ui-clipboard-paste', intent.args);
222
+ return true;
223
+ case 'ui-screenshot-reply':
224
+ enqueueCommand(worldId, 'cmd-ui-screenshot-reply', intent.args);
225
+ return true;
226
+ case 'ui-access-kit-action-request':
227
+ enqueueCommand(worldId, 'cmd-ui-access-kit-action-request', intent.args);
228
+ return true;
229
+ case 'ui-form-upsert': {
230
+ const existing = uiState.forms.get(intent.form.formId);
231
+ uiState.forms.set(intent.form.formId, {
232
+ formId: intent.form.formId,
233
+ windowId: intent.form.windowId,
234
+ realmId: intent.form.realmId,
235
+ documentId: intent.form.documentId,
236
+ disabled: intent.form.disabled ?? false,
237
+ cycleMode: intent.form.cycleMode ?? 'wrap',
238
+ activeFieldsetId: intent.form.activeFieldsetId,
239
+ activeNodeId: existing?.activeNodeId,
240
+ });
241
+ return true;
242
+ }
243
+ case 'ui-form-dispose': {
244
+ uiState.forms.delete(intent.formId);
245
+ for (const [key, fieldset] of uiState.fieldsets) {
246
+ if (fieldset.formId === intent.formId) {
247
+ uiState.fieldsets.delete(key);
248
+ }
249
+ }
250
+ for (const [nodeId, node] of uiState.nodes) {
251
+ if (node.formId === intent.formId) {
252
+ uiState.nodes.delete(nodeId);
253
+ }
254
+ }
255
+ for (const [windowId, focus] of uiState.focusByWindow) {
256
+ if (focus.formId === intent.formId) {
257
+ uiState.focusByWindow.delete(windowId);
258
+ }
259
+ }
260
+ return true;
261
+ }
262
+ case 'ui-fieldset-upsert': {
263
+ uiState.fieldsets.set(fieldsetKey(intent.fieldset.formId, intent.fieldset.fieldsetId), {
264
+ formId: intent.fieldset.formId,
265
+ fieldsetId: intent.fieldset.fieldsetId,
266
+ disabled: intent.fieldset.disabled ?? false,
267
+ legendNodeId: intent.fieldset.legendNodeId,
268
+ });
269
+ return true;
270
+ }
271
+ case 'ui-fieldset-dispose':
272
+ uiState.fieldsets.delete(fieldsetKey(intent.formId, intent.fieldsetId));
273
+ return true;
274
+ case 'ui-focusable-upsert':
275
+ uiState.nodes.set(intent.focusable.nodeId, {
276
+ formId: intent.focusable.formId,
277
+ nodeId: intent.focusable.nodeId,
278
+ tabIndex: intent.focusable.tabIndex ?? 0,
279
+ fieldsetId: intent.focusable.fieldsetId,
280
+ disabled: intent.focusable.disabled ?? false,
281
+ orderHint: intent.focusable.orderHint ?? intent.focusable.nodeId,
282
+ });
283
+ return true;
284
+ case 'ui-focusable-dispose':
285
+ uiState.nodes.delete(intent.nodeId);
286
+ return true;
287
+ case 'ui-focus-next': {
288
+ const next = resolveNextNode(
289
+ uiState,
290
+ intent.windowId,
291
+ intent.backwards ?? false,
292
+ intent.formId,
293
+ );
294
+ if (!next) return true;
295
+
296
+ enqueueCommand(worldId, 'cmd-ui-focus-set', {
297
+ windowId: next.form.windowId,
298
+ realmId: next.form.realmId,
299
+ documentId: next.form.documentId,
300
+ nodeId: next.nodeId,
301
+ });
302
+ applyFocus(uiState, next.form.windowId, next.form.formId, next.nodeId);
303
+ return true;
304
+ }
305
+ default:
306
+ return false;
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Bridges UI-oriented intents into core UI commands and maintains focus state.
312
+ *
313
+ * It also provides default keyboard Tab navigation when explicit
314
+ * `ui-focus-next` intents are not emitted by the application.
315
+ */
316
+ export const UiBridgeSystem: System = (world, context) => {
317
+ const uiState = ensureUiState(world);
318
+ const intentsToRemove: number[] = [];
319
+ let explicitFocusNavigation = false;
320
+
321
+ for (let i = 0; i < world.pendingIntents.length; i++) {
322
+ const intent = world.pendingIntents[i];
323
+ if (!intent) continue;
324
+
325
+ if (intent.type === 'ui-focus-next') {
326
+ explicitFocusNavigation = true;
327
+ }
328
+
329
+ if (processUiIntent(context.worldId, uiState, intent)) {
330
+ intentsToRemove.push(i);
331
+ }
332
+ }
333
+
334
+ if (!explicitFocusNavigation) {
335
+ const inputState = getInputState(world);
336
+ if (inputState?.keysJustPressed.has(KeyCode.Tab)) {
337
+ const backwards =
338
+ inputState.keysPressed.has(KeyCode.ShiftLeft) ||
339
+ inputState.keysPressed.has(KeyCode.ShiftRight);
340
+ const focusWindowId = world.primaryWindowId;
341
+ if (focusWindowId !== undefined) {
342
+ const next = resolveNextNode(uiState, focusWindowId, backwards);
343
+ if (next) {
344
+ enqueueCommand(context.worldId, 'cmd-ui-focus-set', {
345
+ windowId: next.form.windowId,
346
+ realmId: next.form.realmId,
347
+ documentId: next.form.documentId,
348
+ nodeId: next.nodeId,
349
+ });
350
+ applyFocus(uiState, next.form.windowId, next.form.formId, next.nodeId);
351
+ }
352
+ }
353
+ }
354
+ }
355
+
356
+ for (let i = intentsToRemove.length - 1; i >= 0; i--) {
357
+ const idx = intentsToRemove[i];
358
+ if (idx !== undefined) world.pendingIntents.splice(idx, 1);
359
+ }
360
+ };
@@ -9,6 +9,7 @@ import type {
9
9
  import type { TransformComponent } from '../ecs';
10
10
  import type { WorldState } from '../state';
11
11
 
12
+ /** Converts array-like numeric values into a fixed-length tuple buffer. */
12
13
  export function toTuple(value: ArrayLike<number>, length: number): number[] {
13
14
  const result = new Array<number>(length);
14
15
  for (let i = 0; i < length; i++) {
@@ -17,16 +18,19 @@ export function toTuple(value: ArrayLike<number>, length: number): number[] {
17
18
  return result;
18
19
  }
19
20
 
21
+ /** Normalizes arbitrary 2D vector input to a strict tuple. */
20
22
  export function toVec2(value: ArrayLike<number>): [number, number] {
21
23
  const result = toTuple(value, 2);
22
24
  return [result[0] ?? 0, result[1] ?? 0];
23
25
  }
24
26
 
27
+ /** Normalizes arbitrary 3D vector input to a strict tuple. */
25
28
  export function toVec3(value: ArrayLike<number>): [number, number, number] {
26
29
  const result = toTuple(value, 3);
27
30
  return [result[0] ?? 0, result[1] ?? 0, result[2] ?? 0];
28
31
  }
29
32
 
33
+ /** Normalizes arbitrary 4D vector input to a strict tuple. */
30
34
  export function toVec4(value: ArrayLike<number>): [number, number, number, number] {
31
35
  const result = toTuple(value, 4);
32
36
  return [
@@ -37,10 +41,12 @@ export function toVec4(value: ArrayLike<number>): [number, number, number, numbe
37
41
  ];
38
42
  }
39
43
 
44
+ /** Normalizes arbitrary quaternion-like input to a strict `[x, y, z, w]` tuple. */
40
45
  export function toQuat(value: ArrayLike<number>): [number, number, number, number] {
41
46
  return toVec4(value);
42
47
  }
43
48
 
49
+ /** Normalizes standard-material option payload for command serialization. */
44
50
  export function normalizeStandardOptions(options: StandardOptions): StandardOptions {
45
51
  const normalized: StandardOptions = {
46
52
  ...options,
@@ -56,6 +62,7 @@ export function normalizeStandardOptions(options: StandardOptions): StandardOpti
56
62
  return normalized;
57
63
  }
58
64
 
65
+ /** Normalizes PBR-material option payload for command serialization. */
59
66
  export function normalizePbrOptions(options: PbrOptions): PbrOptions {
60
67
  return {
61
68
  ...options,
@@ -64,6 +71,7 @@ export function normalizePbrOptions(options: PbrOptions): PbrOptions {
64
71
  };
65
72
  }
66
73
 
74
+ /** Normalizes polymorphic material options into strict tuple-backed values. */
67
75
  export function normalizeMaterialOptions(
68
76
  options: MaterialOptions | undefined,
69
77
  ): MaterialOptions | undefined {
@@ -80,6 +88,7 @@ export function normalizeMaterialOptions(
80
88
  };
81
89
  }
82
90
 
91
+ /** Normalizes primitive options that contain vector payloads. */
83
92
  export function normalizePrimitiveOptions(options: PrimitiveOptions): PrimitiveOptions {
84
93
  if (options.type === 'cube') {
85
94
  const content = options.content as CubeOptions;
@@ -114,7 +123,11 @@ export function normalizePrimitiveOptions(options: PrimitiveOptions): PrimitiveO
114
123
  return options;
115
124
  }
116
125
 
117
- export function getEntityTransformMatrix(
126
+ /**
127
+ * Resolves the local transform matrix for an entity, without applying constraints
128
+ * (for example parent hierarchy composition).
129
+ */
130
+ export function getEntityLocalTransformMatrix(
118
131
  world: WorldState,
119
132
  entityId: number,
120
133
  ): mat4 {
@@ -145,3 +158,32 @@ export function getEntityTransformMatrix(
145
158
  }
146
159
  return m;
147
160
  }
161
+
162
+ /**
163
+ * Returns the latest constraint-resolved world matrix for an entity.
164
+ * Falls back to local transform when no resolved matrix is cached yet.
165
+ */
166
+ export function getResolvedEntityTransformMatrix(
167
+ world: WorldState,
168
+ entityId: number,
169
+ ): mat4 {
170
+ const resolved = world.resolvedEntityTransforms.get(entityId);
171
+ if (!resolved) {
172
+ return getEntityLocalTransformMatrix(world, entityId);
173
+ }
174
+ return mat4.clone(resolved);
175
+ }
176
+
177
+ /** Compares 4x4 matrices using an absolute epsilon per component. */
178
+ export function mat4EqualsApprox(
179
+ a: ArrayLike<number>,
180
+ b: ArrayLike<number>,
181
+ epsilon = 1e-6,
182
+ ): boolean {
183
+ for (let i = 0; i < 16; i++) {
184
+ if (Math.abs((a[i] ?? 0) - (b[i] ?? 0)) > epsilon) {
185
+ return false;
186
+ }
187
+ }
188
+ return true;
189
+ }
@@ -1,10 +1,17 @@
1
1
  import type { EnvironmentConfig } from '../../types/cmds/environment';
2
2
  import type { ShadowConfig } from '../../types/cmds/shadow';
3
- import type { WindowState } from '../../types/kinds';
4
3
  import { enqueueCommand } from '../bridge/dispatch';
5
4
  import type { System } from '../ecs';
6
- import { toVec2, toVec3 } from './utils';
5
+ import { toVec3, toVec4 } from './utils';
7
6
 
7
+ /**
8
+ * Applies world-scoped lifecycle/configuration intents.
9
+ *
10
+ * Covered intents:
11
+ * - environment configuration
12
+ * - shadow configuration
13
+ * - notification dispatch
14
+ */
8
15
  export const WorldLifecycleSystem: System = (world, context) => {
9
16
  const intentsToRemove: number[] = [];
10
17
 
@@ -12,103 +19,7 @@ export const WorldLifecycleSystem: System = (world, context) => {
12
19
  const intent = world.pendingIntents[i];
13
20
  if (!intent) continue;
14
21
 
15
- if (intent.type === 'create-window') {
16
- const cmd = {
17
- windowId: context.worldId,
18
- title: intent.props.title,
19
- size: toVec2(intent.props.size),
20
- position: toVec2(intent.props.position),
21
- borderless: intent.props.borderless ?? false,
22
- resizable: intent.props.resizable ?? true,
23
- transparent: intent.props.transparent ?? false,
24
- initialState: intent.props.initialState ?? ('windowed' as WindowState),
25
- } as const;
26
- const payload: typeof cmd & { canvasId?: string } = { ...cmd };
27
- if (intent.props.canvasId !== undefined) {
28
- payload.canvasId = intent.props.canvasId;
29
- }
30
- enqueueCommand(context.worldId, 'cmd-window-create', payload);
31
- intentsToRemove.push(i);
32
- } else if (intent.type === 'close-window') {
33
- enqueueCommand(context.worldId, 'cmd-window-close', {
34
- windowId: context.worldId,
35
- });
36
- intentsToRemove.push(i);
37
- } else if (intent.type === 'update-window') {
38
- const p = intent.props;
39
- if (p.title !== undefined) {
40
- enqueueCommand(context.worldId, 'cmd-window-set-title', {
41
- windowId: context.worldId,
42
- title: p.title,
43
- });
44
- }
45
- if (p.position !== undefined) {
46
- enqueueCommand(context.worldId, 'cmd-window-set-position', {
47
- windowId: context.worldId,
48
- position: p.position,
49
- });
50
- }
51
- if (p.size !== undefined) {
52
- enqueueCommand(context.worldId, 'cmd-window-set-size', {
53
- windowId: context.worldId,
54
- size: p.size,
55
- });
56
- }
57
- if (p.state !== undefined) {
58
- enqueueCommand(context.worldId, 'cmd-window-set-state', {
59
- windowId: context.worldId,
60
- state: p.state,
61
- });
62
- }
63
- if (p.resizable !== undefined) {
64
- enqueueCommand(context.worldId, 'cmd-window-set-resizable', {
65
- windowId: context.worldId,
66
- resizable: p.resizable,
67
- });
68
- }
69
- if (p.decorations !== undefined) {
70
- enqueueCommand(context.worldId, 'cmd-window-set-decorations', {
71
- windowId: context.worldId,
72
- decorations: p.decorations,
73
- });
74
- }
75
- if (p.cursorVisible !== undefined) {
76
- enqueueCommand(context.worldId, 'cmd-window-set-cursor-visible', {
77
- windowId: context.worldId,
78
- visible: p.cursorVisible,
79
- });
80
- }
81
- if (p.cursorGrab !== undefined) {
82
- enqueueCommand(context.worldId, 'cmd-window-set-cursor-grab', {
83
- windowId: context.worldId,
84
- mode: p.cursorGrab,
85
- });
86
- }
87
- if (p.icon !== undefined) {
88
- enqueueCommand(context.worldId, 'cmd-window-set-icon', {
89
- windowId: context.worldId,
90
- bufferId: p.icon,
91
- });
92
- }
93
- if (p.cursorIcon !== undefined) {
94
- enqueueCommand(context.worldId, 'cmd-window-set-cursor-icon', {
95
- windowId: context.worldId,
96
- icon: p.cursorIcon,
97
- });
98
- }
99
- intentsToRemove.push(i);
100
- } else if (intent.type === 'request-attention') {
101
- enqueueCommand(context.worldId, 'cmd-window-request-attention', {
102
- windowId: context.worldId,
103
- attentionType: intent.attentionType,
104
- });
105
- intentsToRemove.push(i);
106
- } else if (intent.type === 'focus-window') {
107
- enqueueCommand(context.worldId, 'cmd-window-focus', {
108
- windowId: context.worldId,
109
- });
110
- intentsToRemove.push(i);
111
- } else if (intent.type === 'configure-environment') {
22
+ if (intent.type === 'configure-environment') {
112
23
  const config = intent.config as EnvironmentConfig;
113
24
  const payload: EnvironmentConfig = {
114
25
  msaa: {
@@ -124,6 +35,7 @@ export const WorldLifecycleSystem: System = (world, context) => {
124
35
  skyColor: toVec3(config.skybox.skyColor),
125
36
  cubemapTextureId: config.skybox.cubemapTextureId ?? null,
126
37
  },
38
+ clearColor: toVec4(config.clearColor ?? [0, 0, 0, 0]),
127
39
  post: {
128
40
  filterEnabled: config.post.filterEnabled,
129
41
  filterExposure: config.post.filterExposure,
@@ -157,12 +69,35 @@ export const WorldLifecycleSystem: System = (world, context) => {
157
69
  bloomScatter: config.post.bloomScatter,
158
70
  },
159
71
  };
160
- enqueueCommand(context.worldId, 'cmd-environment-update', {
161
- windowId: context.worldId,
72
+ enqueueCommand(context.worldId, 'cmd-environment-upsert', {
73
+ environmentId: context.worldId,
162
74
  config: payload,
163
75
  });
76
+ // Keep current realm->target bindings synchronized with this environment.
77
+ for (const binding of world.targetLayerBindings.values()) {
78
+ binding.environmentId = context.worldId;
79
+ if (world.coreRealmId === undefined) continue;
80
+ enqueueCommand(context.worldId, 'cmd-target-layer-upsert', {
81
+ realmId: world.coreRealmId,
82
+ targetId: binding.targetId,
83
+ layout: binding.layout,
84
+ cameraId: binding.cameraId,
85
+ environmentId: context.worldId,
86
+ });
87
+ }
164
88
  intentsToRemove.push(i);
165
89
  } else if (intent.type === 'configure-shadows') {
90
+ let windowId = world.primaryWindowId;
91
+ if (windowId === undefined) {
92
+ for (const boundWindowId of world.targetWindowBindings.values()) {
93
+ windowId = boundWindowId;
94
+ break;
95
+ }
96
+ }
97
+ if (windowId === undefined) {
98
+ windowId = world.realmCreateArgs.hostWindowId;
99
+ }
100
+ if (windowId === undefined) continue;
166
101
  const c = intent.config || {};
167
102
  const config: ShadowConfig = {
168
103
  tileResolution: c.tileResolution ?? 1024,
@@ -174,7 +109,7 @@ export const WorldLifecycleSystem: System = (world, context) => {
174
109
  normalBias: c.normalBias ?? 0.01,
175
110
  };
176
111
  enqueueCommand(context.worldId, 'cmd-shadow-configure', {
177
- windowId: context.worldId,
112
+ windowId,
178
113
  config,
179
114
  });
180
115
  intentsToRemove.push(i);