@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
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+
3
+ // The page's microphone for the core's recording (`microphone`): asks with getUserMedia, then a
4
+ // worklet node posts mono input blocks that are copied into WASM memory and pushed to the core.
5
+ // Echo cancellation keeps the game's own sounds (a character repeating you) out of the recording.
6
+
7
+ const BLOCK = 2048;
8
+ const MIC_FAILED = -1;
9
+ export function installMicrophoneInput(core, bridge) {
10
+ if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) return;
11
+ let permission = 0;
12
+ let stream = null;
13
+ let context = null;
14
+ let failed = false;
15
+ /** Bumped by every start and stop, so a slow start that was stopped meanwhile closes itself. */
16
+ let session = 0;
17
+ const startCore = bridge.micStart;
18
+ const stopCore = bridge.micStop;
19
+ const stateCore = bridge.micState;
20
+ const allocate = core.engine_web_alloc;
21
+ const push = core.engine_audio_mic_push;
22
+ let pointer = 0;
23
+
24
+ // A page that was allowed before doesn't prompt again: report it as granted up front.
25
+ void navigator.permissions?.query({
26
+ name: 'microphone'
27
+ }).then(status => {
28
+ const read = () => {
29
+ permission = status.state === 'granted' ? 1 : status.state === 'denied' ? -1 : 0;
30
+ };
31
+ read();
32
+ status.onchange = read;
33
+ }).catch(() => {});
34
+ const open = async () => {
35
+ stream ??= await navigator.mediaDevices.getUserMedia({
36
+ audio: {
37
+ echoCancellation: true,
38
+ noiseSuppression: true,
39
+ autoGainControl: true,
40
+ channelCount: 1
41
+ }
42
+ });
43
+ permission = 1;
44
+ return stream;
45
+ };
46
+ const close = () => {
47
+ stream?.getTracks().forEach(track => track.stop()); // turns the browser's recording indicator off
48
+ stream = null;
49
+ void context?.close().catch(() => {});
50
+ context = null;
51
+ };
52
+ bridge.micPermission = () => permission;
53
+ bridge.micRequestPermission = async () => {
54
+ try {
55
+ await open();
56
+ return true;
57
+ } catch {
58
+ permission = -1;
59
+ return false;
60
+ }
61
+ };
62
+ bridge.micStart = maxSeconds => {
63
+ session++;
64
+ const current = session;
65
+ close();
66
+ failed = false;
67
+ if (!startCore(maxSeconds)) return false;
68
+ void (async () => {
69
+ const input = await open();
70
+ const audio = new AudioContext();
71
+ context = audio;
72
+ await audio.audioWorklet.addModule(new URL('./audio-worklet.js', import.meta.url));
73
+ if (current !== session) return audio.close();
74
+ const node = new AudioWorkletNode(audio, 'nayan-microphone', {
75
+ numberOfInputs: 1,
76
+ numberOfOutputs: 1
77
+ });
78
+ const muted = audio.createGain();
79
+ muted.gain.value = 0; // keeps the node pulled without playing the input back
80
+ audio.createMediaStreamSource(input).connect(node).connect(muted).connect(audio.destination);
81
+ pointer ||= allocate(BLOCK * 4);
82
+ node.port.onmessage = ({
83
+ data
84
+ }) => {
85
+ if (current !== session) return;
86
+ new Float32Array(core.memory.buffer, pointer, data.length).set(data);
87
+ push(pointer, data.length, audio.sampleRate);
88
+ };
89
+ await audio.resume();
90
+ })().catch(() => {
91
+ if (current !== session) return;
92
+ failed = true;
93
+ if (permission === 0) permission = -1;
94
+ stopCore();
95
+ close();
96
+ });
97
+ return true;
98
+ };
99
+ bridge.micStop = () => {
100
+ session++;
101
+ failed = false;
102
+ stopCore();
103
+ close();
104
+ };
105
+ bridge.micState = () => failed ? MIC_FAILED : stateCore();
106
+ }
107
+ //# sourceMappingURL=input.web.js.map
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+
3
+ // Recording from the device's microphone, kept in the Rust core: it holds the last few seconds of
4
+ // input and how loud it is now, and turns any stretch of it into an ordinary Sound that plays through
5
+ // the mixer (at any pitch: a character repeating you in a squeaky voice). Nothing leaves the device.
6
+ //
7
+ // if (!(await microphone.requestPermission())) return; // the system prompt, the first time
8
+ // microphone.start(); // listening (keeps the last 10 s)
9
+ // // each frame: microphone.level (0..1) to notice speech starting and stopping
10
+ // const take = microphone.take(spoke, { skipEnd: 0.6 }); // what was said, without the pause after
11
+ // if (take) audio.play(take.sound, { pitch: 1.6 }); // ... then audio.release(take.sound)
12
+ //
13
+ // iOS: the app needs NSMicrophoneUsageDescription; the audio session switches to play-and-record (to
14
+ // the speaker) while listening and back to ambient after `stop`. Android: the app declares
15
+ // android.permission.RECORD_AUDIO; `requestPermission` asks through the current activity. Web: the
16
+ // page must be served over HTTPS (or localhost) and allowed to use the microphone.
17
+ import NativeEngine from '../native/NativeNayanEngine';
18
+
19
+ /** What `microphone.take` returns: a sound to play, how long it lasts and its loudness over time. */
20
+
21
+ const STATES = {
22
+ 0: 'off',
23
+ 1: 'starting',
24
+ 2: 'listening',
25
+ [-1]: 'failed'
26
+ };
27
+ export const microphone = {
28
+ /** Whether this engine build can record (false on older app builds and without a native module). */
29
+ get supported() {
30
+ return typeof NativeEngine?.micStart === 'function';
31
+ },
32
+ /** Whether the app may record. On Android, 'undetermined' also covers a denial (it can't tell). */
33
+ get permission() {
34
+ const value = microphone.supported ? NativeEngine.micPermission() : -1;
35
+ return value > 0 ? 'granted' : value < 0 ? 'denied' : 'undetermined';
36
+ },
37
+ /** Shows the system prompt the first time (answering at once after that); true if recording is allowed. */
38
+ requestPermission() {
39
+ return microphone.supported ? NativeEngine.micRequestPermission() : Promise.resolve(false);
40
+ },
41
+ /** Starts listening (asynchronously: see `state`). Ask for the permission first. */
42
+ start({
43
+ maxSeconds = 10
44
+ } = {}) {
45
+ return microphone.supported && NativeEngine.micStart(maxSeconds);
46
+ },
47
+ /** Stops listening and releases the microphone (its system indicator turns off); forgets what was heard. */
48
+ stop() {
49
+ if (microphone.supported) NativeEngine.micStop();
50
+ },
51
+ /** 'failed' when there's no microphone, no permission or the input couldn't open. */
52
+ get state() {
53
+ return microphone.supported ? STATES[NativeEngine.micState()] ?? 'off' : 'off';
54
+ },
55
+ /**
56
+ * How loud the input is now: 0 at -60 dBFS or quieter, 1 at full scale; speech close to a phone
57
+ * is usually 0.4..0.8, a quiet room under 0.3. Rises at once and falls back over ~0.15 s. Cheap
58
+ * enough to read every frame.
59
+ */
60
+ get level() {
61
+ return microphone.supported ? NativeEngine.micLevel() : 0;
62
+ },
63
+ /** Seconds of input held (up to `maxSeconds`), since starting or the last `take`. */
64
+ get held() {
65
+ return microphone.supported ? NativeEngine.micHeld() : 0;
66
+ },
67
+ /**
68
+ * The last `seconds` of input (ending `skipEnd` seconds ago) as a new Sound, and forgets
69
+ * everything heard so far so the next take starts fresh. Null if nothing was heard. Release the
70
+ * sound with `audio.release` once it has played: each take holds its samples until then.
71
+ */
72
+ take(seconds = Infinity, {
73
+ skipEnd = 0,
74
+ levelsPerSecond = 30
75
+ } = {}) {
76
+ if (!microphone.supported) return null;
77
+ const id = NativeEngine.micTake(Number.isFinite(seconds) ? seconds : 3600, skipEnd);
78
+ if (id < 0) return null;
79
+ const sound = id;
80
+ const buffer = NativeEngine.audioLevels(sound, levelsPerSecond);
81
+ return {
82
+ sound,
83
+ duration: NativeEngine.audioDuration(sound),
84
+ levels: new Float32Array(buffer),
85
+ levelsPerSecond
86
+ };
87
+ }
88
+ };
89
+ //# sourceMappingURL=microphone.js.map
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+
3
+ import { speechPlatform } from './speechPlatform';
4
+ async function request(command) {
5
+ if (!speechPlatform.supported) throw new Error('Speech is unavailable on this platform or engine build');
6
+ const result = JSON.parse(await speechPlatform.request(JSON.stringify(command)));
7
+ if (result.error) throw new Error(result.error);
8
+ return result.value;
9
+ }
10
+
11
+ /** System text-to-speech. Independent of the engine mixer, microphone and asset loading. */
12
+ export const speech = {
13
+ /** API availability; installed voices and Android TTS initialization may still fail. */
14
+ get supported() {
15
+ return speechPlatform.supported;
16
+ },
17
+ /**
18
+ * Queues text using the system synthesizer. Resolves when queued, not when speech finishes.
19
+ * No audio files are created. Call stop on leaving a screen to cancel its queued speech.
20
+ */
21
+ async speak(text, options = {}) {
22
+ if (typeof text !== 'string') throw new TypeError('Speech text must be a string');
23
+ for (const [key, min, max] of [['rate', 0.1, 2], ['pitch', 0.5, 2], ['volume', 0, 1]]) {
24
+ const value = options[key];
25
+ if (value !== undefined && (!Number.isFinite(value) || value < min || value > max)) {
26
+ throw new RangeError(`Speech ${key} must be between ${min} and ${max}`);
27
+ }
28
+ }
29
+ for (const key of ['language', 'voice']) {
30
+ if (options[key] !== undefined && typeof options[key] !== 'string') throw new TypeError(`Speech ${key} must be a string`);
31
+ }
32
+ if (!text.trim()) return;
33
+ const {
34
+ language,
35
+ voice,
36
+ rate,
37
+ pitch,
38
+ volume
39
+ } = options;
40
+ await request({
41
+ action: 'speak',
42
+ text,
43
+ language,
44
+ voice,
45
+ rate,
46
+ pitch,
47
+ volume
48
+ });
49
+ },
50
+ /** Stops current speech and clears the entire speech queue. */
51
+ async stop() {
52
+ await request({
53
+ action: 'stop'
54
+ });
55
+ },
56
+ /** iOS and web only; rejects on Android, which has no system pause API. */
57
+ async pause() {
58
+ await request({
59
+ action: 'pause'
60
+ });
61
+ },
62
+ /** iOS and web only; rejects on Android. */
63
+ async resume() {
64
+ await request({
65
+ action: 'resume'
66
+ });
67
+ },
68
+ /** Includes speech paused on iOS/web. */
69
+ isSpeakingAsync() {
70
+ return request({
71
+ action: 'isSpeaking'
72
+ });
73
+ },
74
+ getAvailableVoicesAsync() {
75
+ return request({
76
+ action: 'voices'
77
+ });
78
+ }
79
+ };
80
+ //# sourceMappingURL=speech.js.map
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+
3
+ import NativeEngine from '../native/NativeNayanEngine';
4
+ export const speechPlatform = {
5
+ get supported() {
6
+ return typeof NativeEngine?.speechRequest === 'function';
7
+ },
8
+ request(command) {
9
+ return NativeEngine.speechRequest(command);
10
+ }
11
+ };
12
+ //# sourceMappingURL=speechPlatform.js.map
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+
3
+ import { speech_request } from '../../native/core/build/web/engine_core.js';
4
+ import { prepareEngine } from "../native/NativeNayanEngine.web.js";
5
+
6
+ // The browser API is checked without initializing WASM, including during server rendering.
7
+ export const speechPlatform = {
8
+ get supported() {
9
+ return typeof window !== 'undefined' && 'speechSynthesis' in window && 'SpeechSynthesisUtterance' in window;
10
+ },
11
+ async request(command) {
12
+ await prepareEngine();
13
+ return speech_request(command);
14
+ }
15
+ };
16
+ //# sourceMappingURL=speechPlatform.web.js.map
@@ -173,6 +173,18 @@ export function createBrowserEngine(core) {
173
173
  while (end < b.length && b[end] !== 0) end++;
174
174
  return decoder.decode(b.subarray(pointer, end));
175
175
  };
176
+ /** Copies a UTF-8 argument into scratch slot `index`: [pointer, byte length]. */
177
+ const text = (index, value) => {
178
+ const b = encoder.encode(value);
179
+ return [put(index, b), b.length];
180
+ };
181
+ /** A core function's f64 output (asked for its length first) as a new ArrayBuffer. */
182
+ const readDoubles = read => {
183
+ const count = read(0, 0);
184
+ const out = zeroed(2, Math.max(8, count * 8));
185
+ read(out, count);
186
+ return copy(out, count * 8);
187
+ };
176
188
  const geometry = handle => {
177
189
  try {
178
190
  const error = cString(fn('geometry_error', handle));
@@ -404,6 +416,17 @@ export function createBrowserEngine(core) {
404
416
  const [x, y, z] = dimensions('model_size', mesh, 3, true);
405
417
  return [x, y, z];
406
418
  },
419
+ bakeKept(key) {
420
+ const [k, length] = text(0, key);
421
+ return fn('bake_kept', k, length) !== 0;
422
+ },
423
+ modelBake(mesh, spec) {
424
+ const [s, length] = text(0, spec);
425
+ const out = zeroed(1, 16);
426
+ if (!fn('model_bake', mesh, s, length, out)) return [];
427
+ const [texture, x, y, extent] = new Float32Array(u8().slice(out, out + 16).buffer);
428
+ return [texture, x, y, extent];
429
+ },
407
430
  // The browser core is single-threaded: the "background" loads settle at once with the same work.
408
431
  modelLoadAsync(value, center, fit) {
409
432
  return settle(() => engine.modelLoad(value, center, fit));
@@ -519,6 +542,14 @@ export function createBrowserEngine(core) {
519
542
  const buckets = source(4, ranges);
520
543
  ex('render_overlay')(surface, m, c, r, capacity, buckets, ranges.byteLength / 4, count, width, height);
521
544
  },
545
+ renderBackdrop(surface, matrices, colors, regions, ranges, count) {
546
+ const capacity = Math.min(buf(matrices).byteLength / MATRIX_BYTES, buf(colors).byteLength / VEC4_BYTES, buf(regions).byteLength / VEC4_BYTES);
547
+ const m = source(1, matrices);
548
+ const c = source(2, colors);
549
+ const r = source(3, regions);
550
+ const buckets = source(4, ranges);
551
+ ex('render_backdrop')(surface, m, c, r, capacity, buckets, ranges.byteLength / 4, count);
552
+ },
522
553
  renderError: () => string('render_error'),
523
554
  renderBackend: () => string('render_backend'),
524
555
  drawingCreate: (width, height, ratio) => fn('drawing_create', width, height, ratio),
@@ -557,10 +588,101 @@ export function createBrowserEngine(core) {
557
588
  fn('audio_set_muted', +muted);
558
589
  },
559
590
  audioIsRunning: () => fn('audio_is_running') !== 0,
591
+ audioRelease(sound) {
592
+ fn('audio_release', sound);
593
+ },
594
+ audioDuration: sound => fn('audio_duration', sound),
595
+ audioLevels(sound, perSecond) {
596
+ const count = fn('audio_levels', sound, perSecond, 0, 0);
597
+ if (!count) return new ArrayBuffer(0);
598
+ const p = zeroed(0, count * 4);
599
+ fn('audio_levels', sound, perSecond, p, count);
600
+ return copy(p, count * 4);
601
+ },
602
+ // The page's microphone (permission, getUserMedia and the input node) is installed over these
603
+ // by `installMicrophoneInput`; the core keeps what was heard either way.
604
+ micPermission: () => 0,
605
+ micRequestPermission: () => Promise.resolve(false),
606
+ micStart: maxSeconds => fn('audio_mic_start', maxSeconds) !== 0,
607
+ micStop() {
608
+ fn('audio_mic_stop');
609
+ },
610
+ micState: () => fn('audio_mic_state'),
611
+ micLevel: () => fn('audio_mic_level'),
612
+ micHeld: () => fn('audio_mic_held'),
613
+ micTake: (seconds, skipEnd) => fn('audio_mic_take', seconds, skipEnd),
560
614
  hapticsSupported: () => fn('haptics_supported') !== 0,
561
615
  hapticsPlay(taps, throttle) {
562
616
  const p = put(0, new Uint8Array(new Float32Array(taps).buffer));
563
617
  fn('haptics_play', p, taps.length / 3, +throttle);
618
+ },
619
+ worldgenBuild: (recipe, key, prepareTerrain) => {
620
+ const [r, rl] = text(0, recipe);
621
+ const [k, kl] = text(1, key);
622
+ return fn('worldgen_build', r, rl, k, kl, +prepareTerrain) >>> 0;
623
+ },
624
+ // The web core is single-threaded: build now, settle as a promise.
625
+ worldgenBuildAsync: async (recipe, key, prepareTerrain) => engine.worldgenBuild(recipe, key, prepareTerrain),
626
+ worldgenError: () => string('worldgen_error'),
627
+ worldgenKept(key) {
628
+ const [k, kl] = text(0, key);
629
+ return fn('worldgen_kept', k, kl) !== 0;
630
+ },
631
+ worldgenFree(world) {
632
+ fn('worldgen_free', world);
633
+ },
634
+ worldgenInfo: world => string('worldgen_info', [world]),
635
+ worldgenLayer(world, name) {
636
+ const [n, nl] = text(1, name);
637
+ return readDoubles((out, capacity) => fn('worldgen_layer', world, n, nl, out, capacity));
638
+ },
639
+ worldgenReachable: world => readDoubles((out, capacity) => fn('worldgen_reachable', world, out, capacity)),
640
+ worldgenOverview(world) {
641
+ const count = fn('worldgen_overview', world, 0, 0);
642
+ const out = zeroed(0, Math.max(1, count));
643
+ fn('worldgen_overview', world, out, count);
644
+ return copy(out, count);
645
+ },
646
+ worldgenPick: (world, run) => fn('worldgen_pick', world, run) !== 0,
647
+ worldgenTerrain: (world, material) => geometry(fn('worldgen_terrain', world, material)),
648
+ worldgenMesh(recipe) {
649
+ const [r, rl] = text(0, recipe);
650
+ return geometry(fn('worldgen_mesh', r, rl));
651
+ },
652
+ worldgenPaint(vertices, recipe) {
653
+ const [v] = data(vertices);
654
+ const [r, rl] = text(1, recipe);
655
+ const ok = fn('worldgen_paint', v, bytes(vertices).length / 44, r, rl) !== 0;
656
+ if (ok) bytes(vertices).set(u8().subarray(v, v + vertices.byteLength));
657
+ return ok;
658
+ },
659
+ worldgenGround: (world, x, z) => fn('worldgen_ground', world, x, z),
660
+ worldgenSurface: (world, x, z) => fn('worldgen_surface', world, x, z),
661
+ worldgenPlatform: (world, x, z) => fn('worldgen_platform', world, x, z),
662
+ worldgenWalkable: (world, x, z, radius) => fn('worldgen_walkable', world, x, z, radius) !== 0,
663
+ worldgenClearance: (world, x, z, radius) => fn('worldgen_clearance', world, x, z, radius),
664
+ worldgenSolidClearance: (world, x, z, radius, bottom, top) => fn('worldgen_solid_clearance', world, x, z, radius, bottom, top),
665
+ worldgenFieldIndex(world, name) {
666
+ const [n, nl] = text(0, name);
667
+ return fn('worldgen_field_index', world, n, nl);
668
+ },
669
+ worldgenField: (world, field, x, z) => fn('worldgen_field', world, field, x, z),
670
+ worldgenTilt(world, x, z, yaw) {
671
+ const out = zeroed(0, 32);
672
+ fn('worldgen_tilt', world, x, z, yaw, out);
673
+ const at = out >> 3;
674
+ const q = f64();
675
+ return [q[at], q[at + 1], q[at + 2], q[at + 3]];
676
+ },
677
+ worldgenSight: (world, ax, az, bx, bz, radius, step, rise) => fn('worldgen_sight', world, ax, az, bx, bz, radius, step, rise) !== 0,
678
+ worldgenMove(world, x, z, dx, dz, radius, bodies) {
679
+ const list = bytes(bodies);
680
+ const b = put(1, list);
681
+ const p = zeroed(0, 16);
682
+ f64().set([x, z], p >> 3);
683
+ fn('worldgen_move', world, p, dx, dz, radius, b, Math.floor(list.byteLength / 24));
684
+ const q = f64();
685
+ return [q[p >> 3], q[(p >> 3) + 1]];
564
686
  }
565
687
  };
566
688
  return engine;
@@ -2,6 +2,7 @@
2
2
 
3
3
  import init, { attach_canvas, can_warm_asset_record, install_asset_storage, read_saved_asset, restore_asset_record } from '../../native/core/build/web/engine_core.js';
4
4
  import { IndexedDbCache } from "../assets/IndexedDbCache.web.js";
5
+ import { installMicrophoneInput } from "../media/input.web.js";
5
6
  import { installAudioOutput } from "../media/output.web.js";
6
7
  import { createBrowserEngine } from "./BrowserEngine.js";
7
8
  let storage;
@@ -20,6 +21,7 @@ export function prepareEngine() {
20
21
  }
21
22
  const ready = createBrowserEngine(core);
22
23
  ready.audioIsRunning = installAudioOutput(core);
24
+ installMicrophoneInput(core, ready);
23
25
  Object.assign(browser, ready);
24
26
  })().catch(error => {
25
27
  pending = undefined;
@@ -7,10 +7,12 @@ import { resolveColor } from "../world/color.js";
7
7
  import { follow, shake } from "./camera.js";
8
8
  import { createTouchResponder, pixelRatio, Surface, useActive } from './host';
9
9
  import { getMeshIdLimit, uploadBuiltInMeshes } from "./meshes.js";
10
- import { AutoQuality, median, QUALITY_LEVELS } from "./quality.js";
10
+ import { AutoQuality, levelPixelRatio, median, QUALITY_LEVELS, rememberQuality, settledQuality } from "./quality.js";
11
11
  import { jsx as _jsx } from "react/jsx-runtime";
12
12
  const DEFAULT_BACKGROUND = [0.04, 0.05, 0.09];
13
- const FRAME_LEN = 26; // ENGINE_FRAME_LEN
13
+
14
+ // ENGINE_FRAME_LEN, plus the scene scale (26), FXAA (27) and live shadows (28), which older native cores ignore.
15
+ const FRAME_LEN = 29;
14
16
  const WHITE = [1, 1, 1];
15
17
  let nextSurface = 1;
16
18
  const defaultCamera = () => ({
@@ -121,12 +123,12 @@ export function GameView(props) {
121
123
  let presentedAt = 0;
122
124
  if (autoQuality.current?.source !== source) autoQuality.current = {
123
125
  source,
124
- auto: new AutoQuality()
126
+ auto: new AutoQuality(settledQuality(live.current.props.qualityKey, native))
125
127
  };
126
128
  const auto = autoQuality.current.auto;
127
129
  const chosen = () => {
128
130
  const setting = live.current.props.quality ?? 'auto';
129
- return setting === 'auto' ? auto.quality : setting;
131
+ return setting === 'auto' ? [auto.quality, auto.level] : [setting, QUALITY_LEVELS[setting]];
130
132
  };
131
133
  const followed = [0, 0, 0];
132
134
  const shaken = {
@@ -162,10 +164,13 @@ export function GameView(props) {
162
164
  return Math.min(s.count, end);
163
165
  };
164
166
  let overlay = null;
167
+ // The backdrop last sent, and its update count then: sent again only when it changes.
168
+ let backdrop = null;
165
169
  let last = 0;
166
170
  let frames = 0;
167
171
  let fpsStart = 0;
168
172
  let updateTime = 0;
173
+ let publishTime = 0;
169
174
  let renderTime = 0;
170
175
  // This second's frame intervals and draw times: the quality picker looks at their medians, so a
171
176
  // single hiccup (garbage collection, a busy JS frame) never costs quality.
@@ -198,14 +203,16 @@ export function GameView(props) {
198
203
  updateTime += performance.now() - t0;
199
204
  follow(camera, source, followed, dt);
200
205
  const view = shake(camera, shaken, dt);
201
- const quality = chosen();
202
- const level = QUALITY_LEVELS[quality];
206
+ const [quality, level] = chosen();
203
207
  const {
204
208
  width,
205
209
  height
206
210
  } = size.current;
207
- // Phones' GPUs are fill-rate bound: cap the pixels drawn rather than match a 3x+ screen.
208
- const ratio = Math.min(pixelRatio(), level.maxPixelRatio);
211
+ // Phones' GPUs are fill-rate bound: cap the pixels drawn rather than match a 3x+ screen. A level's
212
+ // pixel budget shrinks only the 3D scene (stretched to fill the view): the screen keeps its own
213
+ // resolution, so the overlay's text stays sharp at every level.
214
+ const screenRatio = Math.min(pixelRatio(), level.maxPixelRatio);
215
+ const ratio = levelPixelRatio(level, pixelRatio(), width, height);
209
216
  const f = frameData;
210
217
  f.set(view.eye, 0);
211
218
  f.set(view.target, 3);
@@ -213,7 +220,7 @@ export function GameView(props) {
213
220
  f[7] = view.ortho ?? 0;
214
221
  f.set(light.direction, 8);
215
222
  f[11] = light.ambient;
216
- f[12] = light.shadows ?? true ? 1 : 0;
223
+ f[12] = (light.shadows ?? true) !== false && level.shadows ? 1 : 0;
217
224
  f[13] = light.shadowExtent ?? 30;
218
225
  // Exactly three channels each: either color may carry an alpha that must not spill over.
219
226
  const lightColor = light.color === undefined ? WHITE : resolveColor(light.color);
@@ -225,11 +232,14 @@ export function GameView(props) {
225
232
  f[18] = sky[1];
226
233
  f[19] = sky[2];
227
234
  f[20] = environment.fog;
228
- f[21] = Math.round(width * ratio);
229
- f[22] = Math.round(height * ratio);
235
+ f[21] = Math.round(width * screenRatio);
236
+ f[22] = Math.round(height * screenRatio);
230
237
  f[23] = level.samples;
231
238
  f[24] = level.shadowSize;
232
239
  f[25] = level.softShadows ? 1 : 0;
240
+ f[26] = screenRatio > 0 ? ratio / screenRatio : 1;
241
+ f[27] = level.fxaa ? 1 : 0;
242
+ f[28] = light.shadows === 'backdrop' ? 0 : 1;
233
243
  const t1 = performance.now();
234
244
  if (layer) {
235
245
  if (overlay?.source !== layer) overlay = {
@@ -241,9 +251,27 @@ export function GameView(props) {
241
251
  overlay = null;
242
252
  native.renderOverlay(surfaceId, regions.buffer, regions.buffer, regions.buffer, regions.buffer, 0, 0, 0); // removes it
243
253
  }
254
+ const backSource = current.backdrop ?? source.backdrop;
255
+ const back = backSource && !backSource.isDisposed ? backSource : undefined;
256
+ if (back && typeof native.renderBackdrop === 'function') {
257
+ if (backdrop?.source !== back || backdrop.updates !== (back.updateCount ?? 0)) {
258
+ // A freshly spawned world has no matrices until it's stepped once.
259
+ if (!back.updateCount && back.update) back.update(0);
260
+ backdrop = {
261
+ source: back,
262
+ updates: back.updateCount ?? 0,
263
+ regions: wholeRegions(back)
264
+ };
265
+ native.renderBackdrop(surfaceId, back.matrices.buffer, back.colors.buffer, backdrop.regions.buffer, back.ranges.buffer, drawn(back));
266
+ }
267
+ } else if (backdrop) {
268
+ backdrop = null;
269
+ native.renderBackdrop?.(surfaceId, regions.buffer, regions.buffer, regions.buffer, regions.buffer, 0); // removes it
270
+ }
244
271
  const render = threaded ? native2.publishFrame : native.renderFrame;
245
272
  const rendered = render(surfaceId, f.buffer, source.matrices.buffer, source.colors.buffer, regions.buffer, source.ranges.buffer, drawn(source), viewProj.buffer, source.nativeWorld ?? -1);
246
273
  const drawMs = performance.now() - t1;
274
+ publishTime += drawMs;
247
275
  if (!threaded) {
248
276
  renderTime += drawMs;
249
277
  draws.push(drawMs);
@@ -267,21 +295,25 @@ export function GameView(props) {
267
295
  frameMs = shownFrames > 0 ? Math.max(frameMs, elapsed / shownFrames) : elapsed;
268
296
  renderMs = meanRenderMs = nativeMs;
269
297
  }
270
- auto.report(now, {
298
+ if (current.adaptQuality ?? true) auto.report(now, {
271
299
  frameMs,
272
300
  renderMs
273
- });
301
+ });else auto.hold(now);
302
+ if ((current.quality ?? 'auto') === 'auto') rememberQuality(current.qualityKey, auto.step, native);
274
303
  intervals.length = 0;
275
304
  draws.length = 0;
276
305
  current.onStats?.({
277
306
  fps,
278
307
  updateMs: updateTime / frames,
308
+ publishMs: publishTime / frames,
279
309
  renderMs: meanRenderMs,
280
310
  quality,
311
+ sceneScale: frameData[26],
281
312
  backend: native.renderBackend()
282
313
  });
283
314
  frames = 0;
284
315
  updateTime = 0;
316
+ publishTime = 0;
285
317
  renderTime = 0;
286
318
  fpsStart = now;
287
319
  }