@needle-tools/engine 5.0.3 → 5.1.0-alpha.1

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 (173) hide show
  1. package/CHANGELOG.md +31 -4
  2. package/README.md +6 -7
  3. package/components.needle.json +1 -1
  4. package/dist/{needle-engine.bundle-BXk8jYW3.js → needle-engine.bundle-BGyKqxBH.js} +12394 -11786
  5. package/dist/needle-engine.bundle-CiYtOO2O.min.js +1732 -0
  6. package/dist/needle-engine.bundle-DzVx9Z8D.umd.cjs +1732 -0
  7. package/dist/needle-engine.d.ts +660 -63
  8. package/dist/needle-engine.js +579 -566
  9. package/dist/needle-engine.min.js +1 -1
  10. package/dist/needle-engine.umd.cjs +1 -1
  11. package/dist/{vendor-vHLk8sXu.js → vendor-CAcsI0eU.js} +116 -115
  12. package/dist/{vendor-CntUvmJu.umd.cjs → vendor-CEM38hLE.umd.cjs} +2 -2
  13. package/dist/{vendor-DPbfJJ4d.min.js → vendor-HRlxIBga.min.js} +2 -2
  14. package/lib/engine/api.d.ts +2 -0
  15. package/lib/engine/api.js +2 -0
  16. package/lib/engine/api.js.map +1 -1
  17. package/lib/engine/engine_addressables.js +5 -1
  18. package/lib/engine/engine_addressables.js.map +1 -1
  19. package/lib/engine/engine_animation.d.ts +14 -7
  20. package/lib/engine/engine_animation.js +49 -9
  21. package/lib/engine/engine_animation.js.map +1 -1
  22. package/lib/engine/engine_components.js +33 -4
  23. package/lib/engine/engine_components.js.map +1 -1
  24. package/lib/engine/engine_context.d.ts +7 -2
  25. package/lib/engine/engine_context.js +10 -2
  26. package/lib/engine/engine_context.js.map +1 -1
  27. package/lib/engine/engine_gameobject.d.ts +4 -0
  28. package/lib/engine/engine_gameobject.js.map +1 -1
  29. package/lib/engine/engine_init.js +4 -0
  30. package/lib/engine/engine_init.js.map +1 -1
  31. package/lib/engine/engine_input.js +4 -1
  32. package/lib/engine/engine_input.js.map +1 -1
  33. package/lib/engine/engine_materialpropertyblock.js +0 -19
  34. package/lib/engine/engine_materialpropertyblock.js.map +1 -1
  35. package/lib/engine/engine_networking.d.ts +11 -8
  36. package/lib/engine/engine_networking.js +43 -26
  37. package/lib/engine/engine_networking.js.map +1 -1
  38. package/lib/engine/engine_networking_instantiate.d.ts +100 -5
  39. package/lib/engine/engine_networking_instantiate.js +150 -16
  40. package/lib/engine/engine_networking_instantiate.js.map +1 -1
  41. package/lib/engine/engine_networking_prefabs.d.ts +59 -0
  42. package/lib/engine/engine_networking_prefabs.js +67 -0
  43. package/lib/engine/engine_networking_prefabs.js.map +1 -0
  44. package/lib/engine/engine_physics_rapier.d.ts +3 -0
  45. package/lib/engine/engine_physics_rapier.js +13 -9
  46. package/lib/engine/engine_physics_rapier.js.map +1 -1
  47. package/lib/engine/engine_utils.js +2 -2
  48. package/lib/engine/engine_utils.js.map +1 -1
  49. package/lib/engine/postprocessing/api.d.ts +2 -0
  50. package/lib/engine/postprocessing/api.js +2 -0
  51. package/lib/engine/postprocessing/api.js.map +1 -0
  52. package/lib/engine/postprocessing/index.d.ts +2 -0
  53. package/lib/engine/postprocessing/index.js +2 -0
  54. package/lib/engine/postprocessing/index.js.map +1 -0
  55. package/lib/engine/postprocessing/postprocessing.d.ts +83 -0
  56. package/lib/engine/postprocessing/postprocessing.js +280 -0
  57. package/lib/engine/postprocessing/postprocessing.js.map +1 -0
  58. package/lib/engine/postprocessing/types.d.ts +39 -0
  59. package/lib/engine/postprocessing/types.js +2 -0
  60. package/lib/engine/postprocessing/types.js.map +1 -0
  61. package/lib/engine/webcomponents/WebXRButtons.js +17 -3
  62. package/lib/engine/webcomponents/WebXRButtons.js.map +1 -1
  63. package/lib/engine/xr/NeedleXRSession.d.ts +2 -0
  64. package/lib/engine/xr/NeedleXRSession.js +47 -14
  65. package/lib/engine/xr/NeedleXRSession.js.map +1 -1
  66. package/lib/engine/xr/events.d.ts +30 -3
  67. package/lib/engine/xr/events.js +38 -0
  68. package/lib/engine/xr/events.js.map +1 -1
  69. package/lib/engine/xr/init.d.ts +4 -0
  70. package/lib/engine/xr/init.js +43 -0
  71. package/lib/engine/xr/init.js.map +1 -0
  72. package/lib/engine-components/AnimationUtils.d.ts +4 -1
  73. package/lib/engine-components/AnimationUtils.js +7 -19
  74. package/lib/engine-components/AnimationUtils.js.map +1 -1
  75. package/lib/engine-components/AnimatorController.d.ts +135 -2
  76. package/lib/engine-components/AnimatorController.js +216 -13
  77. package/lib/engine-components/AnimatorController.js.map +1 -1
  78. package/lib/engine-components/SeeThrough.d.ts +0 -2
  79. package/lib/engine-components/SeeThrough.js +0 -89
  80. package/lib/engine-components/SeeThrough.js.map +1 -1
  81. package/lib/engine-components/SyncedRoom.d.ts +4 -0
  82. package/lib/engine-components/SyncedRoom.js +23 -8
  83. package/lib/engine-components/SyncedRoom.js.map +1 -1
  84. package/lib/engine-components/SyncedTransform.js +5 -5
  85. package/lib/engine-components/SyncedTransform.js.map +1 -1
  86. package/lib/engine-components/Voip.d.ts +46 -0
  87. package/lib/engine-components/Voip.js +126 -2
  88. package/lib/engine-components/Voip.js.map +1 -1
  89. package/lib/engine-components/api.d.ts +1 -0
  90. package/lib/engine-components/api.js +1 -0
  91. package/lib/engine-components/api.js.map +1 -1
  92. package/lib/engine-components/codegen/components.d.ts +1 -0
  93. package/lib/engine-components/codegen/components.js +1 -0
  94. package/lib/engine-components/codegen/components.js.map +1 -1
  95. package/lib/engine-components/postprocessing/Effects/Tonemapping.d.ts +5 -2
  96. package/lib/engine-components/postprocessing/Effects/Tonemapping.js +11 -18
  97. package/lib/engine-components/postprocessing/Effects/Tonemapping.js.map +1 -1
  98. package/lib/engine-components/postprocessing/PostProcessingEffect.d.ts +3 -4
  99. package/lib/engine-components/postprocessing/PostProcessingEffect.js +6 -15
  100. package/lib/engine-components/postprocessing/PostProcessingEffect.js.map +1 -1
  101. package/lib/engine-components/postprocessing/PostProcessingHandler.d.ts +2 -1
  102. package/lib/engine-components/postprocessing/PostProcessingHandler.js.map +1 -1
  103. package/lib/engine-components/postprocessing/Volume.d.ts +18 -11
  104. package/lib/engine-components/postprocessing/Volume.js +61 -140
  105. package/lib/engine-components/postprocessing/Volume.js.map +1 -1
  106. package/lib/engine-components/postprocessing/index.d.ts +1 -0
  107. package/lib/engine-components/postprocessing/index.js +1 -0
  108. package/lib/engine-components/postprocessing/index.js.map +1 -1
  109. package/lib/engine-components/postprocessing/utils.d.ts +2 -0
  110. package/lib/engine-components/postprocessing/utils.js +2 -0
  111. package/lib/engine-components/postprocessing/utils.js.map +1 -1
  112. package/lib/engine-components/ui/Canvas.js +2 -2
  113. package/lib/engine-components/ui/Canvas.js.map +1 -1
  114. package/lib/engine-components/ui/Graphic.d.ts +3 -3
  115. package/lib/engine-components/ui/Graphic.js +6 -2
  116. package/lib/engine-components/ui/Graphic.js.map +1 -1
  117. package/lib/engine-components/ui/Text.d.ts +64 -11
  118. package/lib/engine-components/ui/Text.js +154 -45
  119. package/lib/engine-components/ui/Text.js.map +1 -1
  120. package/lib/engine-components/ui/index.d.ts +1 -0
  121. package/lib/engine-components/ui/index.js +1 -0
  122. package/lib/engine-components/ui/index.js.map +1 -1
  123. package/lib/engine-components-experimental/networking/PlayerSync.d.ts +25 -3
  124. package/lib/engine-components-experimental/networking/PlayerSync.js +60 -11
  125. package/lib/engine-components-experimental/networking/PlayerSync.js.map +1 -1
  126. package/package.json +5 -4
  127. package/plugins/common/logger.js +42 -19
  128. package/plugins/vite/ai.d.ts +11 -10
  129. package/plugins/vite/ai.js +305 -31
  130. package/plugins/vite/logger.client.js +4 -3
  131. package/src/engine/api.ts +3 -0
  132. package/src/engine/engine_addressables.ts +4 -1
  133. package/src/engine/engine_animation.ts +47 -9
  134. package/src/engine/engine_components.ts +36 -7
  135. package/src/engine/engine_context.ts +11 -2
  136. package/src/engine/engine_gameobject.ts +5 -0
  137. package/src/engine/engine_init.ts +4 -0
  138. package/src/engine/engine_input.ts +2 -1
  139. package/src/engine/engine_materialpropertyblock.ts +0 -19
  140. package/src/engine/engine_networking.ts +46 -23
  141. package/src/engine/engine_networking_instantiate.ts +160 -18
  142. package/src/engine/engine_networking_prefabs.ts +80 -0
  143. package/src/engine/engine_physics_rapier.ts +14 -9
  144. package/src/engine/engine_utils.ts +2 -2
  145. package/src/engine/postprocessing/api.ts +2 -0
  146. package/src/engine/postprocessing/index.ts +2 -0
  147. package/src/engine/postprocessing/postprocessing.ts +322 -0
  148. package/src/engine/postprocessing/types.ts +43 -0
  149. package/src/engine/webcomponents/WebXRButtons.ts +21 -4
  150. package/src/engine/xr/NeedleXRSession.ts +55 -20
  151. package/src/engine/xr/events.ts +44 -1
  152. package/src/engine/xr/init.ts +49 -0
  153. package/src/engine-components/AnimationUtils.ts +7 -17
  154. package/src/engine-components/AnimatorController.ts +288 -18
  155. package/src/engine-components/SeeThrough.ts +0 -116
  156. package/src/engine-components/SyncedRoom.ts +28 -9
  157. package/src/engine-components/SyncedTransform.ts +5 -5
  158. package/src/engine-components/Voip.ts +129 -2
  159. package/src/engine-components/api.ts +1 -0
  160. package/src/engine-components/codegen/components.ts +1 -0
  161. package/src/engine-components/postprocessing/Effects/Tonemapping.ts +16 -24
  162. package/src/engine-components/postprocessing/PostProcessingEffect.ts +9 -16
  163. package/src/engine-components/postprocessing/PostProcessingHandler.ts +2 -1
  164. package/src/engine-components/postprocessing/Volume.ts +72 -163
  165. package/src/engine-components/postprocessing/index.ts +1 -0
  166. package/src/engine-components/postprocessing/utils.ts +2 -0
  167. package/src/engine-components/ui/Canvas.ts +2 -2
  168. package/src/engine-components/ui/Graphic.ts +7 -3
  169. package/src/engine-components/ui/Text.ts +170 -52
  170. package/src/engine-components/ui/index.ts +2 -1
  171. package/src/engine-components-experimental/networking/PlayerSync.ts +64 -11
  172. package/dist/needle-engine.bundle-CNH61kLA.umd.cjs +0 -1730
  173. package/dist/needle-engine.bundle-Dvh1jROn.min.js +0 -1730
@@ -0,0 +1,322 @@
1
+ import type { EffectComposer } from "postprocessing";
2
+ import type { ToneMapping } from "three";
3
+ import type { EffectComposer as ThreeEffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
4
+
5
+ import { isDevEnvironment } from "../debug/index.js";
6
+ import type { Context } from "../engine_context.js";
7
+ import { DeviceUtilities, getParam } from "../engine_utils.js";
8
+ import type { IPostProcessingEffect, IPostProcessingHandler, ITonemappingEffect } from "./types.js";
9
+
10
+ const debug = getParam("debugpost");
11
+
12
+ /**
13
+ * Core postprocessing stack accessible via `context.postprocessing`.
14
+ * Manages the effect pipeline independently of any specific component.
15
+ *
16
+ * Volumes and individual PostProcessingEffect components add/remove effects
17
+ * to this stack. The stack builds the EffectComposer pipeline when dirty.
18
+ *
19
+ * @example Add an effect directly
20
+ * ```ts
21
+ * const bloom = new BloomEffect({ intensity: 3 });
22
+ * this.context.postprocessing.addEffect(bloom);
23
+ * ```
24
+ *
25
+ * @example Remove an effect
26
+ * ```ts
27
+ * this.context.postprocessing.removeEffect(bloom);
28
+ * ```
29
+ */
30
+ export class PostProcessing {
31
+
32
+ private readonly _context: Context;
33
+ private _handler: IPostProcessingHandler | null = null;
34
+ private readonly _effects: IPostProcessingEffect[] = [];
35
+ private _isDirty: boolean = false;
36
+
37
+ /** Currently active postprocessing effects in the stack */
38
+ get effects(): readonly IPostProcessingEffect[] {
39
+ return this._effects;
40
+ }
41
+
42
+ get dirty() { return this._isDirty; }
43
+ set dirty(value: boolean) { this._isDirty = value; }
44
+
45
+ /** The internal PostProcessingHandler that manages the EffectComposer pipeline */
46
+ get handler(): IPostProcessingHandler | null { return this._handler; }
47
+
48
+ /**
49
+ * The effect composer used to render postprocessing effects.
50
+ * This is set internally by the PostProcessingHandler when effects are applied.
51
+ */
52
+ get composer(): EffectComposer | ThreeEffectComposer | null { return this._composer; }
53
+ set composer(value: EffectComposer | ThreeEffectComposer | null) { this._composer = value; }
54
+ private _composer: EffectComposer | ThreeEffectComposer | null = null;
55
+
56
+ /**
57
+ * Set multisampling to "auto" to automatically adjust the multisampling level based on performance.
58
+ * Set to a number to manually set the multisampling level.
59
+ * @default "auto"
60
+ */
61
+ multisampling: "auto" | number = "auto";
62
+
63
+ /** When enabled, the device pixel ratio will be gradually reduced when FPS is low
64
+ * and restored when performance recovers.
65
+ * @default true
66
+ */
67
+ adaptiveResolution: boolean = true;
68
+
69
+ constructor(context: Context) {
70
+ this._context = context;
71
+ }
72
+
73
+ /**
74
+ * Add a post processing effect to the stack.
75
+ * The effect stack will be rebuilt on the next update.
76
+ */
77
+ addEffect(effect: IPostProcessingEffect): void {
78
+ if (this._effects.includes(effect)) return;
79
+ this._effects.push(effect);
80
+ this._isDirty = true;
81
+ }
82
+
83
+ /**
84
+ * Remove a post processing effect from the stack.
85
+ * The effect stack will be rebuilt on the next update.
86
+ */
87
+ removeEffect(effect: IPostProcessingEffect): void {
88
+ const index = this._effects.indexOf(effect);
89
+ if (index !== -1) {
90
+ this._effects.splice(index, 1);
91
+ this._isDirty = true;
92
+ }
93
+ }
94
+
95
+ /** Mark the stack as dirty so the effects are rebuilt on the next update */
96
+ markDirty(): void {
97
+ this._isDirty = true;
98
+ }
99
+
100
+ // --- Adaptive multisampling state ---
101
+ private _enabledTime: number = -1;
102
+ private _multisampleAutoChangeTime: number = 0;
103
+ private _multisampleAutoDecreaseTime: number = 0;
104
+
105
+ /** @internal Called from the context render loop to update the postprocessing pipeline */
106
+ update(): void {
107
+ const context = this._context;
108
+ if (context.isInXR) return;
109
+
110
+ // Wait for a camera before applying
111
+ if (this._isDirty && context.mainCamera) {
112
+ this.apply();
113
+ }
114
+
115
+ // In tonemapping-only mode, keep renderer values in sync with the active effect
116
+ if (this._tonemappingOnlyActive) {
117
+ const activeEffects = this._effects.filter(e => e.active && e.enabled && e.isToneMapping === true);
118
+ if (activeEffects.length > 0) {
119
+ const effect = activeEffects[activeEffects.length - 1] as ITonemappingEffect;
120
+ context.renderer.toneMapping = effect.threeToneMapping;
121
+ context.renderer.toneMappingExposure = effect.toneMappingExposure;
122
+ }
123
+ return;
124
+ }
125
+
126
+ if (!this._handler || !this._composer || this._handler.composer !== this._composer) return;
127
+
128
+ // The composer is always a pmndrs EffectComposer (created by PostProcessingHandler)
129
+ const composer = this._composer as EffectComposer;
130
+
131
+ // Handle context lost
132
+ if (context.renderer.getContext().isContextLost()) {
133
+ context.renderer.forceContextRestore();
134
+ }
135
+ if (composer.getRenderer() !== context.renderer)
136
+ composer.setRenderer(context.renderer);
137
+
138
+ composer.setMainScene(context.scene);
139
+
140
+ // --- Adaptive multisampling ---
141
+ if (this.multisampling === "auto") {
142
+ if (this._handler.hasSmaaEffect) {
143
+ if (this._handler.multisampling !== 0) {
144
+ this._handler.multisampling = 0;
145
+ if (debug || isDevEnvironment()) {
146
+ console.log(`[PostProcessing] multisampling is disabled because it's set to 'auto' and there is an SMAA effect.\n\nIf you need multisampling consider changing 'auto' to a fixed value (e.g. 4).`);
147
+ }
148
+ }
149
+ }
150
+ else {
151
+ const timeSinceLastChange = context.time.realtimeSinceStartup - this._multisampleAutoChangeTime;
152
+
153
+ if (context.time.realtimeSinceStartup - this._enabledTime > 2
154
+ && timeSinceLastChange > .5
155
+ ) {
156
+ const prev = this._handler.multisampling;
157
+
158
+ if (this._handler.multisampling > 0 && context.time.smoothedFps <= 50) {
159
+ this._multisampleAutoChangeTime = context.time.realtimeSinceStartup;
160
+ this._multisampleAutoDecreaseTime = context.time.realtimeSinceStartup;
161
+ let newMultiSample = this._handler.multisampling * .5;
162
+ newMultiSample = Math.floor(newMultiSample);
163
+ if (newMultiSample != this._handler.multisampling) {
164
+ this._handler.multisampling = newMultiSample;
165
+ }
166
+ if (debug) console.debug(`[PostProcessing] Reduced multisampling from ${prev} to ${this._handler.multisampling}`);
167
+ }
168
+ else if (timeSinceLastChange > 1
169
+ && context.time.smoothedFps >= 59
170
+ && this._handler.multisampling < context.renderer.capabilities.maxSamples
171
+ && context.time.realtimeSinceStartup - this._multisampleAutoDecreaseTime > 10
172
+ ) {
173
+ this._multisampleAutoChangeTime = context.time.realtimeSinceStartup;
174
+ let newMultiSample = this._handler.multisampling <= 0 ? 1 : this._handler.multisampling * 2;
175
+ newMultiSample = Math.floor(newMultiSample);
176
+ if (newMultiSample !== this._handler.multisampling) {
177
+ this._handler.multisampling = newMultiSample;
178
+ }
179
+ if (debug) console.debug(`[PostProcessing] Increased multisampling from ${prev} to ${this._handler.multisampling}`);
180
+ }
181
+ }
182
+ }
183
+ }
184
+ else {
185
+ const newMultiSample = Math.max(0, Math.min(this.multisampling as number, context.renderer.capabilities.maxSamples));
186
+ if (newMultiSample !== this._handler.multisampling)
187
+ this._handler.multisampling = newMultiSample;
188
+ }
189
+
190
+ // --- Adaptive pixel ratio ---
191
+ this._handler.adaptivePixelRatio = this.adaptiveResolution;
192
+ this._handler.updateAdaptivePixelRatio();
193
+
194
+ // Update camera on passes if needed
195
+ if (context.mainCamera) {
196
+ const passes = composer.passes;
197
+ for (const pass of passes) {
198
+ if (pass.mainCamera && pass.mainCamera !== context.mainCamera) {
199
+ composer.setMainCamera(context.mainCamera);
200
+ break;
201
+ }
202
+ }
203
+ }
204
+ }
205
+
206
+ private _lastApplyTime?: number;
207
+ private _rapidApplyCount = 0;
208
+
209
+ // --- Tonemapping-only state ---
210
+ /** When true, tonemapping is applied directly to the renderer (no full pipeline) */
211
+ private _tonemappingOnlyActive = false;
212
+ private _previousToneMapping?: ToneMapping;
213
+ private _previousToneMappingExposure?: number;
214
+
215
+ private apply() {
216
+ if (debug) console.log(`[PostProcessing] Apply stack (${this._effects.length} effects)`);
217
+
218
+ if (isDevEnvironment()) {
219
+ if (this._lastApplyTime !== undefined && Date.now() - this._lastApplyTime < 100) {
220
+ this._rapidApplyCount++;
221
+ if (this._rapidApplyCount === 5)
222
+ console.warn("[PostProcessing] Detected rapid post processing modifications - this might be a bug");
223
+ }
224
+ this._lastApplyTime = Date.now();
225
+ }
226
+
227
+ this._isDirty = false;
228
+
229
+ // Collect active effects
230
+ const activeEffects = this._effects.filter(e => e.active && e.enabled);
231
+
232
+ if (activeEffects.length <= 0) {
233
+ this.restoreTonemapping();
234
+ this._handler?.unapply(false);
235
+ return;
236
+ }
237
+
238
+ // Check if ALL active effects are tonemapping-only
239
+ const allToneMapping = activeEffects.every(e => e.isToneMapping === true);
240
+
241
+ if (allToneMapping) {
242
+ // Use the last tonemapping effect added (last in the array)
243
+ const tonemappingEffect = activeEffects[activeEffects.length - 1] as ITonemappingEffect;
244
+
245
+ if (debug) console.log(`[PostProcessing] Only tonemapping effects in stack — applying directly to renderer`);
246
+
247
+ // Store previous values on first activation
248
+ if (!this._tonemappingOnlyActive) {
249
+ this._previousToneMapping = this._context.renderer.toneMapping as ToneMapping;
250
+ this._previousToneMappingExposure = this._context.renderer.toneMappingExposure;
251
+ this._tonemappingOnlyActive = true;
252
+ }
253
+
254
+ // Apply tonemapping directly to renderer
255
+ this._context.renderer.toneMapping = tonemappingEffect.threeToneMapping;
256
+ this._context.renderer.toneMappingExposure = tonemappingEffect.toneMappingExposure;
257
+
258
+ // Tear down any existing postprocessing pipeline
259
+ this._handler?.unapply(false);
260
+ return;
261
+ }
262
+
263
+ // We have non-tonemapping effects — restore renderer tonemapping if we were in tonemapping-only mode
264
+ this.restoreTonemapping();
265
+
266
+ // Build full postprocessing pipeline
267
+ this.ensureHandler()
268
+ .then(handler => {
269
+ if (!handler) return;
270
+ return handler.apply(activeEffects) as Promise<void> | void;
271
+ })
272
+ .then(() => {
273
+ if (this._handler) {
274
+ if (this.multisampling === "auto") {
275
+ this._handler.multisampling = DeviceUtilities.isMobileDevice()
276
+ ? 2
277
+ : 4;
278
+ }
279
+ else {
280
+ this._handler.multisampling = Math.max(0, Math.min(this.multisampling as number, this._context.renderer.capabilities.maxSamples));
281
+ }
282
+ if (debug) console.debug(`[PostProcessing] Set multisampling to ${this._handler.multisampling} (Is Mobile: ${DeviceUtilities.isMobileDevice()})`);
283
+ }
284
+ });
285
+
286
+ this._enabledTime = this._context.time.realtimeSinceStartup;
287
+ }
288
+
289
+ /** Restore renderer tonemapping to previous values when leaving tonemapping-only mode */
290
+ private restoreTonemapping() {
291
+ if (this._tonemappingOnlyActive) {
292
+ if (this._previousToneMapping !== undefined)
293
+ this._context.renderer.toneMapping = this._previousToneMapping;
294
+ if (this._previousToneMappingExposure !== undefined)
295
+ this._context.renderer.toneMappingExposure = this._previousToneMappingExposure;
296
+ this._tonemappingOnlyActive = false;
297
+ this._previousToneMapping = undefined;
298
+ this._previousToneMappingExposure = undefined;
299
+ if (debug) console.log(`[PostProcessing] Restored renderer tonemapping`);
300
+ }
301
+ }
302
+
303
+ /** Lazily creates the PostProcessingHandler to avoid loading the postprocessing library until actually needed */
304
+ private async ensureHandler(): Promise<IPostProcessingHandler> {
305
+ if (!this._handler) {
306
+ const { PostProcessingHandler } = await import("../../engine-components/postprocessing/PostProcessingHandler.js");
307
+ if (!this._handler) {
308
+ this._handler = new PostProcessingHandler(this._context);
309
+ }
310
+ }
311
+ return this._handler;
312
+ }
313
+
314
+ /** @internal */
315
+ dispose() {
316
+ this.restoreTonemapping();
317
+ this._handler?.dispose();
318
+ this._handler = null;
319
+ this._composer = null;
320
+ this._effects.length = 0;
321
+ }
322
+ }
@@ -0,0 +1,43 @@
1
+ import type { EffectComposer } from "postprocessing";
2
+ import type { ToneMapping } from "three";
3
+ import type { EffectComposer as ThreeEffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
4
+
5
+ /**
6
+ * Minimal interface for a postprocessing effect as seen by the core stack.
7
+ * Implemented by `PostProcessingEffect` in engine-components.
8
+ */
9
+ export interface IPostProcessingEffect {
10
+ readonly active: boolean;
11
+ readonly enabled: boolean;
12
+ /** When true, this effect is a tonemapping effect. The core stack uses this to detect tonemapping-only scenarios. */
13
+ readonly isToneMapping?: boolean;
14
+ }
15
+
16
+ /**
17
+ * Extended interface for tonemapping effects.
18
+ * When ONLY tonemapping effects are in the stack, the core applies tonemapping
19
+ * directly to the renderer instead of creating a full postprocessing pipeline.
20
+ */
21
+ export interface ITonemappingEffect extends IPostProcessingEffect {
22
+ readonly isToneMapping: true;
23
+ /** The three.js ToneMapping enum value to apply to the renderer */
24
+ readonly threeToneMapping: ToneMapping;
25
+ /** The exposure value to apply to the renderer */
26
+ readonly toneMappingExposure: number;
27
+ }
28
+
29
+ /**
30
+ * Interface for the pipeline builder that manages the EffectComposer.
31
+ * Implemented by `PostProcessingHandler` in engine-components.
32
+ */
33
+ export interface IPostProcessingHandler {
34
+ readonly composer: EffectComposer | ThreeEffectComposer | null;
35
+ readonly hasSmaaEffect: boolean;
36
+ multisampling: number;
37
+ adaptivePixelRatio: boolean;
38
+
39
+ apply(effects: IPostProcessingEffect[]): Promise<void>;
40
+ unapply(dispose?: boolean): void;
41
+ updateAdaptivePixelRatio(): void;
42
+ dispose(): void;
43
+ }
@@ -6,7 +6,7 @@ import { onXRSessionEnd, onXRSessionStart } from "../xr/events.js";
6
6
  import { ButtonsFactory } from "./buttons.js";
7
7
  import { getIconElement } from "./icons.js";
8
8
  import { NeedleMenu } from "./needle menu/needle-menu.js";
9
- import { getOrCreateQuicklookHandler,type IQuicklookHandler } from "./quicklook-handler.js";
9
+ import { getOrCreateQuicklookHandler, type IQuicklookHandler } from "./quicklook-handler.js";
10
10
 
11
11
  // TODO: move these buttons into their own web components so their logic is encapsulated (e.g. the CSS animation when a xr session is requested)
12
12
 
@@ -120,10 +120,27 @@ export class WebXRButtonFactory {
120
120
 
121
121
  button.classList.add("webxr-button");
122
122
  button.dataset["needle"] = "webxr-ar-button";
123
- button.innerText = "Enter AR";
124
- button.prepend(getIconElement("view_in_ar"))
125
123
  button.title = "Click to start an AR session";
126
- button.addEventListener("click", () => NeedleXRSession.start(mode, init));
124
+ const setDefaultText = () => {
125
+ button.innerText = "Enter AR";
126
+ button.prepend(getIconElement("view_in_ar"));
127
+ };
128
+ setDefaultText();
129
+
130
+ let tapAgainTimeout: ReturnType<typeof setTimeout>;
131
+ button.addEventListener("click", () => {
132
+
133
+ NeedleXRSession.start(mode, init);
134
+
135
+ if (DeviceUtilities.isiOS() && !DeviceUtilities.isVisionOS()) {
136
+ setTimeout(() => {
137
+ button.innerHTML = "Tap again";
138
+ button.prepend(getIconElement("view_in_ar"));
139
+ clearTimeout(tapAgainTimeout);
140
+ tapAgainTimeout = setTimeout(setDefaultText, 4000);
141
+ }, 500);
142
+ }
143
+ });
127
144
  NeedleMenu.setElementPriority(button, arButtonPriority);
128
145
  this.updateSessionSupported(button, mode);
129
146
  this.listenToXRSessionState(button, mode);
@@ -32,7 +32,7 @@ declare type NeedleXRFrame = XRFrame & { fillPoses?: FillPosesFunction };
32
32
  * Use `args.xr` to access the NeedleXRSession */
33
33
  export type NeedleXREventArgs = { readonly xr: NeedleXRSession }
34
34
  export type SessionChangedEvt = (args: NeedleXREventArgs) => void;
35
- export type SessionRequestedEvent = (args: { readonly mode: XRSessionMode, readonly init: XRSessionInit }) => void;
35
+ export type SessionRequestedEvent = (args: { readonly mode: XRSessionMode, readonly init: XRSessionInit, readonly context: Context }) => void;
36
36
  export type SessionRequestedEndEvent = (args: { readonly mode: XRSessionMode, readonly init: XRSessionInit, newSession: XRSession | null }) => void;
37
37
 
38
38
  /** Result of a XR hit-test
@@ -79,13 +79,9 @@ function getDOMOverlayElement(domElement: HTMLElement) {
79
79
  }
80
80
 
81
81
 
82
-
83
82
  handleSessionGranted();
84
83
  async function handleSessionGranted() {
85
84
 
86
- // await delay(400);
87
-
88
-
89
85
  let defaultMode: XRSessionMode = "immersive-vr";
90
86
 
91
87
  try {
@@ -109,8 +105,6 @@ async function handleSessionGranted() {
109
105
  return;
110
106
  }
111
107
 
112
- // showBalloonMessage("sessiongranted: " + defaultMode);
113
-
114
108
  // TODO: asap session granted doesnt handle the pre-room yet
115
109
  if (getParam("debugasap")) {
116
110
  let asapSession = globalThis["needle:XRSession"] as XRSession | undefined | Promise<XRSession>;
@@ -149,7 +143,6 @@ async function handleSessionGranted() {
149
143
  navigator.xr?.addEventListener('sessiongranted', async () => {
150
144
  // enableSpatialConsole(true);
151
145
 
152
-
153
146
  const lastSessionMode = sessionStorage.getItem("needle_xr_session_mode") as XRSessionMode;
154
147
  const lastSessionInit = sessionStorage.getItem("needle_xr_session_init") ?? null;
155
148
  const init = lastSessionInit ? JSON.parse(lastSessionInit) : null;
@@ -331,7 +324,18 @@ export class NeedleXRSession implements INeedleXRSession {
331
324
  * @param mode The XRSessionMode to check if it is supported
332
325
  * @returns true if the browser supports the given XRSessionMode
333
326
  */
334
- static isSessionSupported(mode: XRSessionMode) { return this.xrSystem?.isSessionSupported(mode).catch(err => { if (debug) console.error(err); return false }) ?? Promise.resolve(false); }
327
+ static isSessionSupported(mode: XRSessionMode) {
328
+ // We cache the result of the session support check to avoid unnecessary calls to isSessionSupported. This is especially important for user input events that internally run session support checks during onclick where the event must be resolved as fast as possible.
329
+ if (this._sessionSupportedCache[mode] !== undefined) {
330
+ return Promise.resolve(this._sessionSupportedCache[mode]);
331
+ }
332
+ return this.xrSystem?.isSessionSupported(mode).then(supported => {
333
+ this._sessionSupportedCache[mode] = supported;
334
+ return supported;
335
+ }).catch(err => { if (debug) console.error(err); return false }) ?? Promise.resolve(false);
336
+ }
337
+
338
+ private static _sessionSupportedCache: { [mode in XRSessionMode]?: boolean } = {};
335
339
 
336
340
  private static _currentSessionRequest?: Promise<XRSession>;
337
341
  private static _activeSession: NeedleXRSession | null;
@@ -466,10 +470,11 @@ export class NeedleXRSession implements INeedleXRSession {
466
470
  // handle iOS platform where "immersive-ar" is special:
467
471
  // - we either launch QuickLook
468
472
  // - or forward to the Needle App Clip experience for WebXR AR
469
- // TODO: should we add a separate mode (e.g. "AR")? https://linear.app/needle/issue/NE-5303
470
473
  if (DeviceUtilities.isiOS()) {
471
474
 
472
- const arSupported = await this.isARSupported().catch(() => false);
475
+ // IMPORTANT: on iOS we should have prefetched immersive-ar support already on app start (this is done in xrInit()) - if not then clicking the Enter AR button for the very first time does not immediately open the AppClip overlay.
476
+ // Without an async await here the overlay *does* however open even when clicking for the very first time. (To test this you need to clear the local experience cache on device)
477
+ const arSupported = this._sessionSupportedCache["immersive-ar"] ?? await this.isARSupported();
473
478
 
474
479
  // On VisionOS, we use QuickLook for AR experiences; no AppClip support for now.
475
480
  if (DeviceUtilities.isVisionOS() && !arSupported && (mode === "ar" || mode === "immersive-ar")) {
@@ -487,13 +492,16 @@ export class NeedleXRSession implements INeedleXRSession {
487
492
 
488
493
  if (!arSupported && (mode === "immersive-ar" || mode === "ar")) {
489
494
 
490
- this.invokeSessionRequestStart("immersive-ar", init);
495
+ this.invokeSessionRequestStart("immersive-ar", init, context ?? Context.Current);
496
+
497
+ // if we are in an iframe, we need to navigate the top window
498
+ const topWindow = window.top || window;
499
+ const originalUrl = topWindow.location.href;
491
500
 
492
501
  // Forward to the AppClip experience (Using the apple.com url the appclip overlay shows immediately)
493
502
  // const url =`https://appclip.needle.tools/ar?url=${(location.href)}`;
494
503
  const url = new URL("https://appclip.apple.com/id?p=tools.needle.launch-app.Clip");
495
504
  url.searchParams.set("url", location.href);
496
-
497
505
  const urlStr = url.toString();
498
506
 
499
507
  Telemetry.sendEvent(Context.Current, "xr", {
@@ -502,16 +510,43 @@ export class NeedleXRSession implements INeedleXRSession {
502
510
  url: urlStr,
503
511
  });
504
512
 
505
- // if we are in an iframe, we need to navigate the top window
506
- const topWindow = window.top || window;
507
513
  try {
508
514
  console.debug("iOS device detected - opening Needle App Clip for AR experience", { mode, init, url });
515
+
516
+ // Prewarm the appclip by opening the URL
517
+ // BUT on safari triggering this with multiple URLs causes the
518
+ // Browser to show the dialogue AGAIN in the background
519
+ // SO: we don't do it here...
520
+ const key = "appclip-prewarm:" + new URL(url).origin;
521
+ const allowMultipleNavigations = sessionStorage.getItem(key) === null;
522
+ if (allowMultipleNavigations) {
523
+ const secondUrl = new URL(url);
524
+ secondUrl.searchParams.set("prewarm", "1");
525
+ // eslint-disable-next-line xss/no-location-href-assign
526
+ topWindow.location.href = secondUrl.toString();
527
+
528
+ // wait a bit to give the device time to fetch the app clip metadata - this seems to fix the double tap issue (500 ms seem to work)
529
+ await new Promise(res => setTimeout(res, 300));
530
+ }
531
+
509
532
  // navigate to app clip url but keep the current url in history, open in same tab
510
533
  // eslint-disable-next-line xss/no-location-href-assign
511
534
  topWindow.location.href = urlStr;
535
+
536
+ if (allowMultipleNavigations && !DeviceUtilities.isSafari()) {
537
+ setTimeout(() => {
538
+ const url2 = new URL(url);
539
+ url2.searchParams.set("prewarm", "1");
540
+ // eslint-disable-next-line xss/no-location-href-assign
541
+ topWindow.location.href = url2.toString();
542
+ }, 500);
543
+ }
544
+
545
+ sessionStorage.setItem(key, "1");
546
+
512
547
  }
513
548
  catch (e) {
514
- console.warn("Error navigating to AppClip " + urlStr + "\n", e);
549
+ console.warn(`Error navigating to AppClip ${urlStr}\n`, e);
515
550
  // if top window navigation fails and we are in an iframe, we try to navigate the top window directly
516
551
  const weAreInIframe = window !== window.top;
517
552
  if (weAreInIframe) {
@@ -531,7 +566,7 @@ export class NeedleXRSession implements INeedleXRSession {
531
566
  }
532
567
 
533
568
  if (mode === "quicklook") {
534
- console.warn("QuickLook mode is only supported on iOS devices");
569
+ console.error("QuickLook mode is only supported on iOS devices.");
535
570
  return null;
536
571
  }
537
572
 
@@ -627,7 +662,7 @@ export class NeedleXRSession implements INeedleXRSession {
627
662
  script.onBeforeXR(mode, init);
628
663
  }
629
664
  }
630
- this.invokeSessionRequestStart(mode, init);
665
+ this.invokeSessionRequestStart(mode, init, context ?? Context.Current);
631
666
  if (debug) showBalloonMessage("Requesting " + mode + " session (" + Date.now() + ")");
632
667
  Telemetry.sendEvent(Context.Current, "xr", {
633
668
  action: "session_request",
@@ -658,9 +693,9 @@ export class NeedleXRSession implements INeedleXRSession {
658
693
  return session;
659
694
  }
660
695
 
661
- private static invokeSessionRequestStart(mode: XRSessionMode, init: XRSessionInit) {
696
+ private static invokeSessionRequestStart(mode: XRSessionMode, init: XRSessionInit, context: Context) {
662
697
  for (const listener of this._sessionRequestStartListeners) {
663
- listener({ mode, init });
698
+ listener({ mode, init, context });
664
699
  }
665
700
  }
666
701
  private static invokeSessionRequestEnd(mode: XRSessionMode, init: XRSessionInit, session: XRSession | null | undefined | void) {
@@ -1,4 +1,5 @@
1
- import type { NeedleXRSession } from "./NeedleXRSession.js";
1
+ import { NeedleXRSession } from "./NeedleXRSession.js";
2
+ import type { Context } from "../engine_context.js";
2
3
 
3
4
  export declare type XRSessionEventArgs = { session: NeedleXRSession };
4
5
 
@@ -14,11 +15,13 @@ const onXRSessionStartListeners: ((evt: XRSessionEventArgs) => void)[] = [];
14
15
  * console.log("XR session started", evt);
15
16
  * });
16
17
  * ```
18
+ * @returns A function to remove the listener
17
19
  */
18
20
  export function onXRSessionStart(fn: (evt: XRSessionEventArgs) => void) {
19
21
  if (onXRSessionStartListeners.indexOf(fn) === -1) {
20
22
  onXRSessionStartListeners.push(fn);
21
23
  }
24
+ return () => offXRSessionStart(fn);
22
25
  }
23
26
  /**
24
27
  * Remove a listener for when an XR session starts
@@ -51,11 +54,13 @@ const onXRSessionEndListeners: ((evt: XRSessionEventArgs) => void)[] = [];
51
54
  * console.log("XR session ended", evt);
52
55
  * });
53
56
  * ```
57
+ * @returns A function to remove the listener
54
58
  */
55
59
  export function onXRSessionEnd(fn: (evt: XRSessionEventArgs) => void) {
56
60
  if (onXRSessionEndListeners.indexOf(fn) === -1) {
57
61
  onXRSessionEndListeners.push(fn);
58
62
  }
63
+ return () => offXRSessionEnd(fn);
59
64
  };
60
65
 
61
66
  /**
@@ -78,6 +83,44 @@ export function offXRSessionEnd(fn: (evt: XRSessionEventArgs) => void) {
78
83
  }
79
84
 
80
85
 
86
+ export declare type XRSessionRequestEventArgs = { readonly mode: XRSessionMode, readonly init: XRSessionInit, readonly context: Context };
87
+
88
+ /**
89
+ * Add a listener that fires before an XR session is requested.
90
+ * Use this to modify the session init options, e.g. to add optional features like `camera-access`.
91
+ * @param fn The function to call before the XR session is requested
92
+ * @example
93
+ * ```js
94
+ * onBeforeXRSession((args) => {
95
+ * args.init.optionalFeatures ??= [];
96
+ * args.init.optionalFeatures.push("camera-access");
97
+ * });
98
+ * ```
99
+ * @return A function to remove the listener
100
+ */
101
+ export function onBeforeXRSession(fn: (args: XRSessionRequestEventArgs) => void) {
102
+ if (onBeforeXRSessionListeners.indexOf(fn) === -1) {
103
+ onBeforeXRSessionListeners.push(fn);
104
+ // Delegate to NeedleXRSession which owns the actual invocation
105
+ NeedleXRSession.onSessionRequestStart(fn);
106
+ }
107
+ return () => offBeforeXRSession(fn);
108
+ }
109
+
110
+ /**
111
+ * Remove a listener for before an XR session is requested
112
+ * @param fn The function to remove from the listeners
113
+ */
114
+ export function offBeforeXRSession(fn: (args: XRSessionRequestEventArgs) => void) {
115
+ const index = onBeforeXRSessionListeners.indexOf(fn);
116
+ if (index !== -1) {
117
+ onBeforeXRSessionListeners.splice(index, 1);
118
+ NeedleXRSession.offSessionRequestStart(fn);
119
+ }
120
+ }
121
+
122
+ const onBeforeXRSessionListeners: ((args: XRSessionRequestEventArgs) => void)[] = [];
123
+
81
124
  /**
82
125
  * @internal
83
126
  * Invoke the XRSessionStart event