@nayan-ui/engine 1.0.0-beta.5 → 1.0.0-beta.6

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 (77) hide show
  1. package/README.md +95 -4
  2. package/lib/module/assets/ProceduralCache.js +5 -0
  3. package/lib/module/assets/loft.js +15 -63
  4. package/lib/module/assets/procedural.js +500 -0
  5. package/lib/module/assets/shape.js +147 -245
  6. package/lib/module/assets/texture.js +65 -0
  7. package/lib/module/drawing/DrawingDocument.js +1 -1
  8. package/lib/module/index.js +4 -1
  9. package/lib/module/media/audio-worklet.js +27 -0
  10. package/lib/module/media/audio.js +20 -0
  11. package/lib/module/media/input.web.js +107 -0
  12. package/lib/module/media/microphone.js +89 -0
  13. package/lib/module/media/speech.js +80 -0
  14. package/lib/module/media/speechPlatform.js +12 -0
  15. package/lib/module/media/speechPlatform.web.js +16 -0
  16. package/lib/module/native/BrowserEngine.js +122 -0
  17. package/lib/module/native/NativeNayanEngine.web.js +2 -0
  18. package/lib/module/render/GameView.js +45 -13
  19. package/lib/module/render/quality.js +151 -17
  20. package/lib/module/world/World.js +63 -0
  21. package/lib/typescript/src/assets/ProceduralCache.d.ts +2 -0
  22. package/lib/typescript/src/assets/loft.d.ts +3 -1
  23. package/lib/typescript/src/assets/procedural.d.ts +394 -0
  24. package/lib/typescript/src/assets/shape.d.ts +13 -7
  25. package/lib/typescript/src/assets/texture.d.ts +63 -0
  26. package/lib/typescript/src/index.d.ts +8 -2
  27. package/lib/typescript/src/media/audio.d.ts +13 -0
  28. package/lib/typescript/src/media/input.web.d.ts +4 -0
  29. package/lib/typescript/src/media/microphone.d.ts +51 -0
  30. package/lib/typescript/src/media/speech.d.ts +38 -0
  31. package/lib/typescript/src/media/speechPlatform.d.ts +5 -0
  32. package/lib/typescript/src/media/speechPlatform.web.d.ts +5 -0
  33. package/lib/typescript/src/native/EngineBridge.d.ts +7 -1
  34. package/lib/typescript/src/native/spec/NativeNayanEngine.d.ts +72 -0
  35. package/lib/typescript/src/render/GameView.d.ts +30 -3
  36. package/lib/typescript/src/render/quality.d.ts +54 -2
  37. package/lib/typescript/src/types.d.ts +9 -2
  38. package/lib/typescript/src/world/World.d.ts +16 -0
  39. package/lib/typescript/src/world/types.d.ts +8 -0
  40. package/lib/web/audio-worklet.js +27 -0
  41. package/lib/web/engine_core_bg.wasm +0 -0
  42. package/lib/web/index.js +1181 -422
  43. package/lib/web/types/src/assets/ProceduralCache.d.ts +2 -0
  44. package/lib/web/types/src/assets/loft.d.ts +3 -1
  45. package/lib/web/types/src/assets/procedural.d.ts +393 -0
  46. package/lib/web/types/src/assets/shape.d.ts +13 -7
  47. package/lib/web/types/src/assets/texture.d.ts +63 -0
  48. package/lib/web/types/src/index.d.ts +8 -2
  49. package/lib/web/types/src/media/audio.d.ts +13 -0
  50. package/lib/web/types/src/media/input.d.ts +3 -0
  51. package/lib/web/types/src/media/microphone.d.ts +50 -0
  52. package/lib/web/types/src/media/speech.d.ts +37 -0
  53. package/lib/web/types/src/media/speechPlatform.d.ts +4 -0
  54. package/lib/web/types/src/native/EngineBridge.d.ts +7 -1
  55. package/lib/web/types/src/native/spec/NativeNayanEngine.d.ts +72 -0
  56. package/lib/web/types/src/render/GameView.d.ts +30 -3
  57. package/lib/web/types/src/render/quality.d.ts +54 -2
  58. package/lib/web/types/src/types.d.ts +9 -2
  59. package/lib/web/types/src/world/World.d.ts +16 -0
  60. package/lib/web/types/src/world/types.d.ts +8 -0
  61. package/native/android/src/main/AndroidManifest.xml +10 -0
  62. package/native/android/src/main/java/com/nayanui/engine/EngineMicrophone.java +60 -0
  63. package/native/android/src/main/java/com/nayanui/engine/EngineSpeech.java +28 -0
  64. package/native/android/src/main/java/com/nayanui/engine/EngineSurfaceView.java +1 -0
  65. package/native/android/src/main/java/com/nayanui/engine/NayanEnginePackage.java +2 -0
  66. package/native/core/build/EngineCore.xcframework/ios-arm64/libengine_core.a +0 -0
  67. package/native/core/build/EngineCore.xcframework/ios-arm64-simulator/libengine_core.a +0 -0
  68. package/native/core/build/android/arm64-v8a/libengine_core.a +0 -0
  69. package/native/core/build/android/armeabi-v7a/libengine_core.a +0 -0
  70. package/native/core/build/android/x86/libengine_core.a +0 -0
  71. package/native/core/build/android/x86_64/libengine_core.a +0 -0
  72. package/native/core/include/engine_core.h +63 -1
  73. package/native/cpp/NayanEngineModule.cpp +296 -0
  74. package/native/cpp/NayanEngineModule.h +40 -0
  75. package/native/cpp/generated/NayanEngineSpecJSI.h +397 -0
  76. package/package.json +1 -1
  77. package/src/native/spec/NativeNayanEngine.ts +77 -0
@@ -1,44 +1,93 @@
1
1
  "use strict";
2
2
 
3
3
  // Rendering quality: how many pixels are drawn and how much each costs. Phones' GPUs are mostly
4
- // limited by fill rate, so resolution and anti-aliasing matter most, then shadow quality.
4
+ // limited by fill rate, so resolution and anti-aliasing matter most, then shadows.
5
5
 
6
6
  /** `"auto"` starts at "high" and steps down only while the device really can't keep up. */
7
7
 
8
8
  /** From best looking to cheapest; "auto" walks down this list only as far as it must. */
9
9
  export const QUALITIES = ['high', 'medium', 'low', 'lowest'];
10
10
  export const QUALITY_LEVELS = {
11
- // Ordered by what each step costs the eye: shadow detail goes first (barely noticed), then
12
- // anti-aliasing; only the last resort draws fewer pixels, since anything below the screen's own
13
- // resolution is stretched up and looks soft (text most of all).
11
+ // Ordered by what each step costs the eye. Shadows always use a full-resolution map: a low-resolution
12
+ // map's soft, smeared edges look worse than no shadow. "medium" keeps them with crisp single-tap
13
+ // edges (cheaper than the 3x3 filter); "low" drops the shadow pass entirely (which also saves its
14
+ // GPU time) and anti-aliasing; only the last resort draws fewer pixels, since anything below the screen's own resolution is stretched up and looks
15
+ // soft (text most of all). Fill rate dominates a low-end GPU: an Adreno 610 tablet drew 2.4 MP in
16
+ // 42 ms and 0.78 MP in 21 ms (23 -> 41 fps).
14
17
  high: {
15
18
  maxPixelRatio: 3,
16
19
  samples: 4,
20
+ shadows: true,
17
21
  shadowSize: 2048,
18
22
  softShadows: true
19
23
  },
20
24
  medium: {
21
25
  maxPixelRatio: 3,
22
26
  samples: 4,
23
- shadowSize: 1024,
24
- softShadows: true
27
+ shadows: true,
28
+ shadowSize: 2048,
29
+ softShadows: false
25
30
  },
26
31
  low: {
27
32
  maxPixelRatio: 3,
28
33
  samples: 1,
29
- shadowSize: 1024,
30
- softShadows: false
34
+ shadows: false,
35
+ shadowSize: 2048,
36
+ softShadows: true
31
37
  },
32
38
  lowest: {
33
39
  maxPixelRatio: 2,
34
40
  samples: 1,
35
- shadowSize: 512,
36
- softShadows: false
41
+ fxaa: true,
42
+ shadows: false,
43
+ shadowSize: 2048,
44
+ softShadows: true,
45
+ maxPixels: 800_000
37
46
  }
38
47
  };
39
48
 
40
- /** A typical frame slower than this (under ~50 fps) is a frame the device can't hold. */
41
- const SLOW_FRAME_MS = 20;
49
+ /**
50
+ * What "auto" walks through, from best to cheapest: a level, and the most pixels its 3D scene is drawn
51
+ * with. Before giving up an effect it draws the scene a little smaller, as native games scale their
52
+ * resolution: stretched over the screen under the full-resolution HUD, 1.5 MP (0.8x a 1200x2000
53
+ * tablet) can't be told from the screen's own resolution and 1.1 MP barely, while every effect stays.
54
+ * On the Adreno 610 tablet that took Crossy Road from 47 to 60 fps and Whack a Mole from 53 to 59 at
55
+ * "high": a tile-based GPU's cost under 4x MSAA grows with pixels times the triangles in each tile.
56
+ */
57
+
58
+ export const AUTO_STEPS = [{
59
+ quality: 'high'
60
+ }, {
61
+ quality: 'high',
62
+ maxPixels: 1_500_000
63
+ }, {
64
+ quality: 'high',
65
+ maxPixels: 1_100_000
66
+ }, {
67
+ quality: 'medium',
68
+ maxPixels: 1_100_000
69
+ }, {
70
+ quality: 'low',
71
+ maxPixels: 1_100_000
72
+ }, {
73
+ quality: 'lowest'
74
+ }];
75
+
76
+ /** A step's level: its quality's, with the step's pixel budget when that is smaller. */
77
+ export function stepLevel(step) {
78
+ const level = QUALITY_LEVELS[step.quality];
79
+ if (!step.maxPixels || level.maxPixels && level.maxPixels <= step.maxPixels) return level;
80
+ return {
81
+ ...level,
82
+ maxPixels: step.maxPixels
83
+ };
84
+ }
85
+
86
+ /**
87
+ * A typical frame slower than this (under ~54 fps) is a frame the device can't hold: on a 60 Hz screen
88
+ * that is a visible stutter, and the first steps down cost only a little resolution.
89
+ */
90
+ const SLOW_FRAME_MS = 18.5;
42
91
  /** Under ~33 fps the game is plainly struggling: one such second is enough to step down. */
43
92
  const VERY_SLOW_FRAME_MS = 30;
44
93
  /** A typical frame this quick (60 fps with room to spare) is one it holds comfortably. */
@@ -52,16 +101,69 @@ const SETTLE_MS = 1500;
52
101
  const SLOW_SECONDS = 3;
53
102
  const VERY_SLOW_SECONDS = 1;
54
103
  const FAST_SECONDS = 10;
104
+ /**
105
+ * Seconds with the GPU drawing in at most this share of a 60 Hz frame: plenty of headroom (a light
106
+ * board game on a level below what it can hold, e.g. one remembered from a heavier version), so step
107
+ * up after only `HEADROOM_SECONDS` instead of FAST_SECONDS. These needn't be smooth: a frame now and
108
+ * then that the game loop (not drawing) drops can't be helped by a lower level, and would otherwise keep
109
+ * a game that draws in a few milliseconds at 57 fps on its remembered level for good.
110
+ */
111
+ const HEADROOM_RENDER_MS = 10;
112
+ const HEADROOM_SECONDS = 2;
55
113
  /** Stepping down again this soon after a step up means that level is too much: never return to it. */
56
114
  const RELAPSE_MS = 20_000;
57
115
 
116
+ /** The pixel ratio a level draws a `width` x `height` point view at, on a screen of ratio `screen`. */
117
+ export function levelPixelRatio(level, screen, width, height) {
118
+ const ratio = Math.min(screen, level.maxPixelRatio);
119
+ const area = width * height;
120
+ if (!level.maxPixels || area <= 0) return ratio;
121
+ return Math.min(ratio, Math.sqrt(level.maxPixels / area));
122
+ }
123
+
124
+ /** Durable records (the engine's Rust store); absent on builds without it, which then remember per session. */
125
+
126
+ // Steps of AUTO_STEPS (the older `quality:` records held a level's index).
127
+ const storeKey = key => `quality-step:${key}`;
128
+
129
+ /**
130
+ * The step (of AUTO_STEPS) "auto" settled on per `qualityKey` (e.g. a game's id), kept in `store`
131
+ * across app launches: every visit of the same game starts there instead of spending its first seconds
132
+ * at "high" on a device that can't hold it (it still steps back up after smooth seconds).
133
+ */
134
+ const settled = new Map();
135
+ export function settledQuality(key, store) {
136
+ if (!key) return undefined;
137
+ const known = settled.get(key);
138
+ if (known !== undefined || typeof store?.cacheRead !== 'function') return known;
139
+ try {
140
+ const bytes = new Uint8Array(store.cacheRead(storeKey(key)));
141
+ const step = bytes.length === 1 && bytes[0] < AUTO_STEPS.length ? bytes[0] : undefined;
142
+ if (step !== undefined) settled.set(key, step);
143
+ return step;
144
+ } catch {
145
+ return undefined;
146
+ }
147
+ }
148
+ /** Records the step for `key`, writing it to `store` only when it changes. */
149
+ export function rememberQuality(key, step, store) {
150
+ if (!key || settledQuality(key, store) === step) return;
151
+ settled.set(key, step);
152
+ try {
153
+ store?.cacheWrite?.(storeKey(key), new Uint8Array([step]).buffer);
154
+ } catch {
155
+ // Remembered for this session; the next launch starts from the stored (or default) step.
156
+ }
157
+ }
158
+
58
159
  /** One second of frames, as the frame loop measured it. */
59
160
 
60
161
  /**
61
- * Picks the level for "auto": starts at the best, steps down one level after `SLOW_SECONDS`
162
+ * Picks the step (of AUTO_STEPS) for "auto": starts at the best, steps down one after `SLOW_SECONDS`
62
163
  * consecutive seconds whose typical frame misses ~50 fps while drawing took most of it (after a
63
164
  * single second when it misses ~33 fps, so a struggling phone recovers in seconds rather than
64
- * stuttering through every level), and steps back up after `FAST_SECONDS` smooth seconds. A level that fails again soon after being restored
165
+ * stuttering through every level), and steps back up after `FAST_SECONDS` smooth seconds (`HEADROOM_SECONDS` when
166
+ * the GPU has plenty of time to spare). A level that fails again soon after being restored
65
167
  * becomes the ceiling, so it never bounces.
66
168
  */
67
169
  export class AutoQuality {
@@ -74,8 +176,25 @@ export class AutoQuality {
74
176
  slow = 0;
75
177
  verySlow = 0;
76
178
  fast = 0;
179
+ headroom = 0;
180
+
181
+ /** `start`: the step to begin at (one remembered from an earlier visit), or a quality's first step. */
182
+ constructor(start = 0) {
183
+ const index = typeof start === 'number' ? Math.floor(start) : AUTO_STEPS.findIndex(s => s.quality === start);
184
+ this.index = Math.min(Math.max(0, index), AUTO_STEPS.length - 1);
185
+ }
186
+
187
+ /** The current step's index in AUTO_STEPS. */
188
+ get step() {
189
+ return this.index;
190
+ }
77
191
  get quality() {
78
- return QUALITIES[this.index];
192
+ return AUTO_STEPS[this.index].quality;
193
+ }
194
+
195
+ /** What to draw with: the step's level and pixel budget. */
196
+ get level() {
197
+ return stepLevel(AUTO_STEPS[this.index]);
79
198
  }
80
199
 
81
200
  /** Feeds one second of frames; returns true when the level changed. */
@@ -87,24 +206,39 @@ export class AutoQuality {
87
206
  this.slow = gpuBound ? this.slow + 1 : 0;
88
207
  this.verySlow = gpuBound && frames.frameMs > VERY_SLOW_FRAME_MS ? this.verySlow + 1 : 0;
89
208
  this.fast = smooth ? this.fast + 1 : 0;
90
- if ((this.slow >= SLOW_SECONDS || this.verySlow >= VERY_SLOW_SECONDS) && this.index < QUALITIES.length - 1) {
209
+ this.headroom = !gpuBound && frames.renderMs <= HEADROOM_RENDER_MS ? this.headroom + 1 : 0;
210
+ if ((this.slow >= SLOW_SECONDS || this.verySlow >= VERY_SLOW_SECONDS) && this.index < AUTO_STEPS.length - 1) {
91
211
  // Failing again right after being restored: this level is too much for the device
92
212
  if (now - this.raisedAt < RELAPSE_MS) this.ceiling = Math.max(this.ceiling, this.index + 1);
93
213
  this.index++;
94
214
  return this.moved(now);
95
215
  }
96
- if (this.fast >= FAST_SECONDS && this.index > this.ceiling) {
216
+ if ((this.fast >= FAST_SECONDS || this.headroom >= HEADROOM_SECONDS) && this.index > this.ceiling) {
97
217
  this.index--;
98
218
  this.raisedAt = now;
99
219
  return this.moved(now);
100
220
  }
101
221
  return false;
102
222
  }
223
+
224
+ /**
225
+ * Frames that don't count (a results card over the game): forgets the streaks and, once frames
226
+ * count again, waits `SETTLE_MS` before judging, as after a change of level.
227
+ */
228
+ hold(now) {
229
+ if (!this.started) this.started = now;
230
+ this.changed = now;
231
+ this.slow = 0;
232
+ this.verySlow = 0;
233
+ this.fast = 0;
234
+ this.headroom = 0;
235
+ }
103
236
  moved(now) {
104
237
  this.changed = now;
105
238
  this.slow = 0;
106
239
  this.verySlow = 0;
107
240
  this.fast = 0;
241
+ this.headroom = 0;
108
242
  return true;
109
243
  }
110
244
  }
@@ -77,6 +77,10 @@ export class World {
77
77
  boundsValue = null;
78
78
  listenerValue = null;
79
79
  updates = 0;
80
+ /** The layer still entities live in, the stand-in each one has here, and whether one changed. */
81
+ backdropValue = null;
82
+ stills = new Map();
83
+ stillChanged = false;
80
84
  constructor(capacity) {
81
85
  this.capacity = capacity;
82
86
  if (!NativeEngine) {
@@ -114,6 +118,7 @@ export class World {
114
118
 
115
119
  /** Adds an entity (one native call; text adds one per letter). Throws if the world is full or an option is invalid. */
116
120
  spawn(options = {}) {
121
+ if (options.still) return this.spawnStill(options);
117
122
  if (options.parent != null && options.physics != null) {
118
123
  throw new Error("World.spawn: attached entities can't have physics (put it on the parent)");
119
124
  }
@@ -146,12 +151,57 @@ export class World {
146
151
  return e;
147
152
  }
148
153
 
154
+ /**
155
+ * The still entities' layer (see `EntityOptions.still`), drawn once into a cached image behind this
156
+ * world; null until it's first used. GameView draws it; it's disposed with this world.
157
+ */
158
+ get backdrop() {
159
+ return this.backdropValue;
160
+ }
161
+
162
+ /**
163
+ * The still layer itself, made on first use with room for `capacity` entities: for a whole still
164
+ * scene (a meadow) spawned straight into it. Step it (`update(0)`) after changing it, which is
165
+ * what tells GameView to draw it again; it isn't stepped otherwise, so nothing in it moves.
166
+ */
167
+ backdropLayer(capacity = 64) {
168
+ if (this.disposed) throw new Error('World: used after dispose()');
169
+ return this.backdropValue ??= new World(capacity);
170
+ }
171
+ spawnStill({
172
+ still: _,
173
+ ...options
174
+ }) {
175
+ if (options.parent != null || options.physics != null || options.text !== undefined) {
176
+ throw new Error("World.spawn: a still entity can't be attached or have physics or text");
177
+ }
178
+ const layer = this.backdropLayer();
179
+ const entity = layer.spawn({
180
+ pickable: false,
181
+ ...options
182
+ });
183
+ // A stand-in here keeps the handle this world's own (unique, and cleared with it)
184
+ const standIn = this.spawn({
185
+ mesh: 'none',
186
+ pickable: false
187
+ });
188
+ this.stills.set(standIn, entity);
189
+ this.stillChanged = true;
190
+ return standIn;
191
+ }
192
+
149
193
  /**
150
194
  * Changes an entity (one native call). Only the options you pass change; `null` removes.
151
195
  * Returns false if the entity is gone, the world is disposed, or an option was rejected (the others still apply).
152
196
  */
153
197
  set(e, options) {
154
198
  if (this.disposed) return false;
199
+ const still = this.stills.get(e);
200
+ if (still !== undefined) {
201
+ if (options.parent != null || options.physics != null) throw new Error("World.set: a still entity can't be attached or have physics");
202
+ this.stillChanged = true;
203
+ return this.backdropValue.set(still, options);
204
+ }
155
205
  const text = this.texts.get(e);
156
206
  // A text entity stays an invisible root; its letters are rebuilt or recolored below.
157
207
  encode(this.desc, options, text ? undefined : this.resolveMesh(options, 'set'));
@@ -203,6 +253,12 @@ export class World {
203
253
  /** Removes an entity and anything attached to it. Returns false if it was already gone. */
204
254
  despawn(e) {
205
255
  if (this.disposed) return false;
256
+ const still = this.stills.get(e);
257
+ if (still !== undefined) {
258
+ this.stills.delete(e);
259
+ this.backdropValue.despawn(still);
260
+ this.stillChanged = true;
261
+ }
206
262
  const existed = this.native.despawn(this.id, e);
207
263
  this.texts.delete(e);
208
264
  this.resolveFinished(); // its animations, and those of attached entities that went with it
@@ -667,6 +723,11 @@ export class World {
667
723
  this.resolveFinished();
668
724
  this.runFallbackTimers(dt);
669
725
  if (!this.disposed) this.scoreboardValue?.dispatch();
726
+ // Still entities changed: step their layer once, which tells GameView to have it drawn again
727
+ if (this.stillChanged && this.backdropValue && !this.disposed) {
728
+ this.stillChanged = false;
729
+ this.backdropValue.update(0);
730
+ }
670
731
  }
671
732
 
672
733
  /**
@@ -698,6 +759,8 @@ export class World {
698
759
  this.pending.clear();
699
760
  this.timers.clear();
700
761
  this.fallbackTimers = [];
762
+ this.stills.clear();
763
+ this.backdropValue?.dispose();
701
764
  NativeEngine.destroyWorld(this.id);
702
765
  }
703
766
  }
@@ -24,6 +24,8 @@ export declare class ProceduralCache {
24
24
  private failures;
25
25
  private writes;
26
26
  constructor(revision: string, storage?: ProceduralStorage | undefined);
27
+ /** The engine storage key of `key` in this revision (for records the native core reads and writes itself). */
28
+ storageKey(key: string): string;
27
29
  get stats(): {
28
30
  hits: number;
29
31
  builds: number;
@@ -1,5 +1,6 @@
1
1
  import type { Vec3 } from '../types.js';
2
2
  import type { Color } from '../world/types.js';
3
+ import { FieldFunction } from './procedural.js';
3
4
  import { type Geometry } from './shape.js';
4
5
  export interface LoftSection {
5
6
  z: number;
@@ -14,7 +15,8 @@ export interface LoftSection {
14
15
  export interface LoftOptions {
15
16
  around?: number;
16
17
  smoothness?: number;
17
- color?: Color | ((position: Vec3, normal: Vec3) => Color);
18
+ /** One color, a function of position and normal, or a `surfaceColor` field function (native). */
19
+ color?: Color | ((position: Vec3, normal: Vec3) => Color) | FieldFunction;
18
20
  }
19
21
  /**
20
22
  * Smooth closed body through rounded rectangular cross sections along Z. Useful for vehicle