@needle-tools/engine 5.0.2 → 5.1.0-canary.525aa82

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 (176) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +6 -7
  3. package/SKILL.md +39 -21
  4. package/components.needle.json +1 -1
  5. package/dist/needle-engine.bundle-DPag02s9.min.js +1732 -0
  6. package/dist/needle-engine.bundle-IPMzQpe1.umd.cjs +1732 -0
  7. package/dist/{needle-engine.bundle-BoTyA-Le.js → needle-engine.bundle-qa_NEunk.js} +8881 -8148
  8. package/dist/needle-engine.d.ts +633 -61
  9. package/dist/needle-engine.js +576 -565
  10. package/dist/needle-engine.min.js +1 -1
  11. package/dist/needle-engine.umd.cjs +1 -1
  12. package/dist/{vendor-vHLk8sXu.js → vendor-CAcsI0eU.js} +116 -115
  13. package/dist/{vendor-CntUvmJu.umd.cjs → vendor-CEM38hLE.umd.cjs} +2 -2
  14. package/dist/{vendor-DPbfJJ4d.min.js → vendor-HRlxIBga.min.js} +2 -2
  15. package/lib/engine/api.d.ts +2 -0
  16. package/lib/engine/api.js +2 -0
  17. package/lib/engine/api.js.map +1 -1
  18. package/lib/engine/engine_addressables.js +5 -1
  19. package/lib/engine/engine_addressables.js.map +1 -1
  20. package/lib/engine/engine_animation.d.ts +14 -7
  21. package/lib/engine/engine_animation.js +49 -9
  22. package/lib/engine/engine_animation.js.map +1 -1
  23. package/lib/engine/engine_components.js +33 -4
  24. package/lib/engine/engine_components.js.map +1 -1
  25. package/lib/engine/engine_context.d.ts +7 -2
  26. package/lib/engine/engine_context.js +10 -2
  27. package/lib/engine/engine_context.js.map +1 -1
  28. package/lib/engine/engine_gameobject.d.ts +4 -0
  29. package/lib/engine/engine_gameobject.js.map +1 -1
  30. package/lib/engine/engine_init.js +4 -0
  31. package/lib/engine/engine_init.js.map +1 -1
  32. package/lib/engine/engine_input.js +4 -1
  33. package/lib/engine/engine_input.js.map +1 -1
  34. package/lib/engine/engine_materialpropertyblock.js +1 -20
  35. package/lib/engine/engine_materialpropertyblock.js.map +1 -1
  36. package/lib/engine/engine_networking.d.ts +11 -8
  37. package/lib/engine/engine_networking.js +43 -26
  38. package/lib/engine/engine_networking.js.map +1 -1
  39. package/lib/engine/engine_networking_instantiate.d.ts +100 -5
  40. package/lib/engine/engine_networking_instantiate.js +150 -16
  41. package/lib/engine/engine_networking_instantiate.js.map +1 -1
  42. package/lib/engine/engine_networking_prefabs.d.ts +59 -0
  43. package/lib/engine/engine_networking_prefabs.js +67 -0
  44. package/lib/engine/engine_networking_prefabs.js.map +1 -0
  45. package/lib/engine/engine_physics_rapier.d.ts +3 -0
  46. package/lib/engine/engine_physics_rapier.js +13 -9
  47. package/lib/engine/engine_physics_rapier.js.map +1 -1
  48. package/lib/engine/postprocessing/api.d.ts +2 -0
  49. package/lib/engine/postprocessing/api.js +2 -0
  50. package/lib/engine/postprocessing/api.js.map +1 -0
  51. package/lib/engine/postprocessing/index.d.ts +2 -0
  52. package/lib/engine/postprocessing/index.js +2 -0
  53. package/lib/engine/postprocessing/index.js.map +1 -0
  54. package/lib/engine/postprocessing/postprocessing.d.ts +83 -0
  55. package/lib/engine/postprocessing/postprocessing.js +280 -0
  56. package/lib/engine/postprocessing/postprocessing.js.map +1 -0
  57. package/lib/engine/postprocessing/types.d.ts +39 -0
  58. package/lib/engine/postprocessing/types.js +2 -0
  59. package/lib/engine/postprocessing/types.js.map +1 -0
  60. package/lib/engine/webcomponents/WebXRButtons.js +17 -3
  61. package/lib/engine/webcomponents/WebXRButtons.js.map +1 -1
  62. package/lib/engine/webcomponents/icons.js +3 -1
  63. package/lib/engine/webcomponents/icons.js.map +1 -1
  64. package/lib/engine/xr/NeedleXRSession.d.ts +1 -0
  65. package/lib/engine/xr/NeedleXRSession.js +43 -10
  66. package/lib/engine/xr/NeedleXRSession.js.map +1 -1
  67. package/lib/engine/xr/init.d.ts +4 -0
  68. package/lib/engine/xr/init.js +49 -0
  69. package/lib/engine/xr/init.js.map +1 -0
  70. package/lib/engine-components/AnimationUtils.d.ts +4 -1
  71. package/lib/engine-components/AnimationUtils.js +7 -19
  72. package/lib/engine-components/AnimationUtils.js.map +1 -1
  73. package/lib/engine-components/AnimatorController.d.ts +135 -2
  74. package/lib/engine-components/AnimatorController.js +216 -13
  75. package/lib/engine-components/AnimatorController.js.map +1 -1
  76. package/lib/engine-components/GroundProjection.d.ts +1 -0
  77. package/lib/engine-components/GroundProjection.js +184 -48
  78. package/lib/engine-components/GroundProjection.js.map +1 -1
  79. package/lib/engine-components/OrbitControls.d.ts +4 -0
  80. package/lib/engine-components/OrbitControls.js +5 -1
  81. package/lib/engine-components/OrbitControls.js.map +1 -1
  82. package/lib/engine-components/SeeThrough.d.ts +0 -2
  83. package/lib/engine-components/SeeThrough.js +0 -89
  84. package/lib/engine-components/SeeThrough.js.map +1 -1
  85. package/lib/engine-components/SyncedRoom.d.ts +4 -0
  86. package/lib/engine-components/SyncedRoom.js +23 -8
  87. package/lib/engine-components/SyncedRoom.js.map +1 -1
  88. package/lib/engine-components/SyncedTransform.js +5 -5
  89. package/lib/engine-components/SyncedTransform.js.map +1 -1
  90. package/lib/engine-components/Voip.d.ts +46 -0
  91. package/lib/engine-components/Voip.js +126 -2
  92. package/lib/engine-components/Voip.js.map +1 -1
  93. package/lib/engine-components/api.d.ts +1 -0
  94. package/lib/engine-components/api.js +1 -0
  95. package/lib/engine-components/api.js.map +1 -1
  96. package/lib/engine-components/codegen/components.d.ts +1 -0
  97. package/lib/engine-components/codegen/components.js +1 -0
  98. package/lib/engine-components/codegen/components.js.map +1 -1
  99. package/lib/engine-components/postprocessing/Effects/Tonemapping.d.ts +5 -2
  100. package/lib/engine-components/postprocessing/Effects/Tonemapping.js +11 -18
  101. package/lib/engine-components/postprocessing/Effects/Tonemapping.js.map +1 -1
  102. package/lib/engine-components/postprocessing/PostProcessingEffect.d.ts +3 -4
  103. package/lib/engine-components/postprocessing/PostProcessingEffect.js +6 -15
  104. package/lib/engine-components/postprocessing/PostProcessingEffect.js.map +1 -1
  105. package/lib/engine-components/postprocessing/PostProcessingHandler.d.ts +2 -1
  106. package/lib/engine-components/postprocessing/PostProcessingHandler.js.map +1 -1
  107. package/lib/engine-components/postprocessing/Volume.d.ts +18 -11
  108. package/lib/engine-components/postprocessing/Volume.js +61 -140
  109. package/lib/engine-components/postprocessing/Volume.js.map +1 -1
  110. package/lib/engine-components/postprocessing/index.d.ts +1 -0
  111. package/lib/engine-components/postprocessing/index.js +1 -0
  112. package/lib/engine-components/postprocessing/index.js.map +1 -1
  113. package/lib/engine-components/postprocessing/utils.d.ts +2 -0
  114. package/lib/engine-components/postprocessing/utils.js +2 -0
  115. package/lib/engine-components/postprocessing/utils.js.map +1 -1
  116. package/lib/engine-components/ui/Canvas.js +2 -2
  117. package/lib/engine-components/ui/Canvas.js.map +1 -1
  118. package/lib/engine-components/ui/Graphic.d.ts +3 -3
  119. package/lib/engine-components/ui/Graphic.js +6 -2
  120. package/lib/engine-components/ui/Graphic.js.map +1 -1
  121. package/lib/engine-components/ui/Text.d.ts +64 -11
  122. package/lib/engine-components/ui/Text.js +154 -45
  123. package/lib/engine-components/ui/Text.js.map +1 -1
  124. package/lib/engine-components/ui/index.d.ts +1 -0
  125. package/lib/engine-components/ui/index.js +1 -0
  126. package/lib/engine-components/ui/index.js.map +1 -1
  127. package/lib/engine-components-experimental/networking/PlayerSync.d.ts +25 -3
  128. package/lib/engine-components-experimental/networking/PlayerSync.js +60 -11
  129. package/lib/engine-components-experimental/networking/PlayerSync.js.map +1 -1
  130. package/package.json +6 -5
  131. package/plugins/vite/ai.d.ts +11 -10
  132. package/plugins/vite/ai.js +305 -31
  133. package/src/engine/api.ts +3 -0
  134. package/src/engine/engine_addressables.ts +4 -1
  135. package/src/engine/engine_animation.ts +47 -9
  136. package/src/engine/engine_components.ts +36 -7
  137. package/src/engine/engine_context.ts +11 -2
  138. package/src/engine/engine_gameobject.ts +5 -0
  139. package/src/engine/engine_init.ts +4 -0
  140. package/src/engine/engine_input.ts +2 -1
  141. package/src/engine/engine_materialpropertyblock.ts +1 -20
  142. package/src/engine/engine_networking.ts +46 -23
  143. package/src/engine/engine_networking_instantiate.ts +160 -18
  144. package/src/engine/engine_networking_prefabs.ts +80 -0
  145. package/src/engine/engine_physics_rapier.ts +14 -9
  146. package/src/engine/postprocessing/api.ts +2 -0
  147. package/src/engine/postprocessing/index.ts +2 -0
  148. package/src/engine/postprocessing/postprocessing.ts +322 -0
  149. package/src/engine/postprocessing/types.ts +43 -0
  150. package/src/engine/webcomponents/WebXRButtons.ts +21 -4
  151. package/src/engine/webcomponents/icons.ts +5 -3
  152. package/src/engine/xr/NeedleXRSession.ts +50 -15
  153. package/src/engine/xr/init.ts +56 -0
  154. package/src/engine-components/AnimationUtils.ts +7 -17
  155. package/src/engine-components/AnimatorController.ts +288 -18
  156. package/src/engine-components/GroundProjection.ts +226 -52
  157. package/src/engine-components/OrbitControls.ts +5 -1
  158. package/src/engine-components/SeeThrough.ts +0 -116
  159. package/src/engine-components/SyncedRoom.ts +28 -9
  160. package/src/engine-components/SyncedTransform.ts +5 -5
  161. package/src/engine-components/Voip.ts +129 -2
  162. package/src/engine-components/api.ts +1 -0
  163. package/src/engine-components/codegen/components.ts +1 -0
  164. package/src/engine-components/postprocessing/Effects/Tonemapping.ts +16 -24
  165. package/src/engine-components/postprocessing/PostProcessingEffect.ts +9 -16
  166. package/src/engine-components/postprocessing/PostProcessingHandler.ts +2 -1
  167. package/src/engine-components/postprocessing/Volume.ts +72 -163
  168. package/src/engine-components/postprocessing/index.ts +1 -0
  169. package/src/engine-components/postprocessing/utils.ts +2 -0
  170. package/src/engine-components/ui/Canvas.ts +2 -2
  171. package/src/engine-components/ui/Graphic.ts +7 -3
  172. package/src/engine-components/ui/Text.ts +170 -52
  173. package/src/engine-components/ui/index.ts +2 -1
  174. package/src/engine-components-experimental/networking/PlayerSync.ts +64 -11
  175. package/dist/needle-engine.bundle-B3ywqx5o.min.js +0 -1654
  176. package/dist/needle-engine.bundle-CzOPcOui.umd.cjs +0 -1654
@@ -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);
@@ -1,6 +1,5 @@
1
1
  import { Texture } from "three";
2
2
 
3
-
4
3
  const fontname = "Material Symbols Outlined";
5
4
 
6
5
  /** Returns a HTML element containing an icon. Using https://fonts.google.com/icons
@@ -21,7 +20,10 @@ export function getIconElement(str: string): HTMLElement {
21
20
  span.setAttribute("aria-label", str + " icon");
22
21
  span.setAttribute("aria-hidden", "true");
23
22
  fontReady(fontname).then(res => {
24
- if (res) span.style.visibility = "";
23
+ if (res) {
24
+ span.style.visibility = "";
25
+ span.innerText = str; // re-assign, otherise more_vert doesnt show up. Maybe because of layout restructuring on mobile
26
+ }
25
27
  else {
26
28
  if (str === "more_vert") {
27
29
  span.style.visibility = "";
@@ -31,7 +33,7 @@ export function getIconElement(str: string): HTMLElement {
31
33
  span.style.display = "none";
32
34
  }
33
35
  }
34
- })
36
+ });
35
37
  return span;
36
38
  }
37
39
 
@@ -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")) {
@@ -489,11 +494,14 @@ export class NeedleXRSession implements INeedleXRSession {
489
494
 
490
495
  this.invokeSessionRequestStart("immersive-ar", init);
491
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;
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
 
@@ -0,0 +1,56 @@
1
+ import { isDevEnvironment, showBalloonMessage } from "../debug/index.js";
2
+ import { DeviceUtilities, setParamWithoutReload } from "../engine_utils.js";
3
+ import { NeedleXRSession } from "./NeedleXRSession.js";
4
+
5
+ /**
6
+ * Initialize XR subsystem. Called from `initEngine()`
7
+ */
8
+ export function initXR() {
9
+ // Prewarm AR support check
10
+ NeedleXRSession.isARSupported();
11
+
12
+
13
+
14
+ if (DeviceUtilities.isiOS()) {
15
+
16
+ if (isDevEnvironment()) {
17
+ const randomParameterValue = Date.now().toString();
18
+ setParamWithoutReload("debug_appclip", randomParameterValue);
19
+ showBalloonMessage("iOS appclip debug: " + randomParameterValue);
20
+ }
21
+
22
+ // Prefetch
23
+ const url = new URL("https://appclip.apple.com/id?p=tools.needle.launch-app.Clip");
24
+ url.searchParams.set("url", location.href);
25
+ const urlStr = url.toString();
26
+ fetch(urlStr, { method: "HEAD", mode: "no-cors" }).catch(() => {
27
+ // appclip prefetch - to get metadata faster on iOS devices, this seems to fix the double tap issue when opening the appclip for AR sessions.
28
+ });
29
+
30
+ try {
31
+ // We add the meta tag here to preload app clip card data for iOS.
32
+ const meta = window.top?.document.querySelector('meta[name="apple-itunes-app"]');
33
+ if(!meta) {
34
+ const metaTag = document.createElement("meta");
35
+ metaTag.name = "apple-itunes-app";
36
+ metaTag.content = "app-id=6757205152, app-clip-bundle-id=tools.needle.launch-app.Clip, app-clip-display=card";
37
+ window.top?.document.head.appendChild(metaTag);
38
+ }
39
+ }
40
+ catch (e) {
41
+ console.warn("Error adding apple-itunes-app meta tag for appclip support\n", e);
42
+ }
43
+
44
+ try {
45
+ // We preconnect to apple here to speed up the appclip meta request for the first click. NOT sure if necessary and working but can't hurt either?
46
+ const topWindow = window.top || window;
47
+ const preconnectMeta = topWindow.document.createElement("link");
48
+ preconnectMeta.rel = "preconnect";
49
+ preconnectMeta.href = url.toString();
50
+ topWindow.document.head.appendChild(preconnectMeta);
51
+ }
52
+ catch (e) {
53
+ console.warn("Error adding preconnect link for appclip.apple.com\n", e);
54
+ }
55
+ }
56
+ }
@@ -1,29 +1,19 @@
1
1
  import { Object3D } from "three";
2
2
 
3
- const $objectAnimationKey = Symbol("objectIsAnimatedData");
3
+ import { AnimationUtils } from "../engine/engine_animation.js";
4
4
 
5
5
  /** Internal method - This marks an object as being animated. Make sure to always call isAnimated=false if you stop animating the object
6
6
  * @param obj The object to mark
7
7
  * @param isAnimated Whether the object is animated or not
8
+ * @deprecated Use {@link AnimationUtils.setObjectAnimated} instead
8
9
  */
9
10
  export function setObjectAnimated(obj: Object3D, animatedBy: object, isAnimated: boolean) {
10
- if (!obj) return;
11
- if (obj[$objectAnimationKey] === undefined) {
12
- if (!isAnimated) return;
13
- obj[$objectAnimationKey] = new Set<object>();
14
- }
15
-
16
- const set = obj[$objectAnimationKey] as Set<object>;
17
- if (isAnimated) {
18
- set.add(animatedBy);
19
- }
20
- else if (set.has(animatedBy))
21
- set.delete(animatedBy);
11
+ return AnimationUtils.setObjectAnimated(obj, animatedBy, isAnimated);
22
12
  }
23
13
 
24
- /** Get is the object is currently animated. Currently used by the Animator to check if a timeline animationtrack is actively animating an object */
14
+ /** Get is the object is currently animated. Currently used by the Animator to check if a timeline animationtrack is actively animating an object
15
+ * @deprecated Use {@link AnimationUtils.getObjectAnimated} instead
16
+ */
25
17
  export function getObjectAnimated(obj: Object3D): boolean {
26
- if (!obj) return false;
27
- const set = obj[$objectAnimationKey] as Set<object>;
28
- return set !== undefined && set.size > 0;
18
+ return AnimationUtils.getObjectAnimated(obj) || false;
29
19
  }