@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.
- package/README.md +95 -4
- package/lib/module/assets/ProceduralCache.js +5 -0
- package/lib/module/assets/loft.js +15 -63
- package/lib/module/assets/procedural.js +500 -0
- package/lib/module/assets/shape.js +147 -245
- package/lib/module/assets/texture.js +65 -0
- package/lib/module/drawing/DrawingDocument.js +1 -1
- package/lib/module/index.js +4 -1
- package/lib/module/media/audio-worklet.js +27 -0
- package/lib/module/media/audio.js +20 -0
- package/lib/module/media/input.web.js +107 -0
- package/lib/module/media/microphone.js +89 -0
- package/lib/module/media/speech.js +80 -0
- package/lib/module/media/speechPlatform.js +12 -0
- package/lib/module/media/speechPlatform.web.js +16 -0
- package/lib/module/native/BrowserEngine.js +122 -0
- package/lib/module/native/NativeNayanEngine.web.js +2 -0
- package/lib/module/render/GameView.js +45 -13
- package/lib/module/render/quality.js +151 -17
- package/lib/module/world/World.js +63 -0
- package/lib/typescript/src/assets/ProceduralCache.d.ts +2 -0
- package/lib/typescript/src/assets/loft.d.ts +3 -1
- package/lib/typescript/src/assets/procedural.d.ts +394 -0
- package/lib/typescript/src/assets/shape.d.ts +13 -7
- package/lib/typescript/src/assets/texture.d.ts +63 -0
- package/lib/typescript/src/index.d.ts +8 -2
- package/lib/typescript/src/media/audio.d.ts +13 -0
- package/lib/typescript/src/media/input.web.d.ts +4 -0
- package/lib/typescript/src/media/microphone.d.ts +51 -0
- package/lib/typescript/src/media/speech.d.ts +38 -0
- package/lib/typescript/src/media/speechPlatform.d.ts +5 -0
- package/lib/typescript/src/media/speechPlatform.web.d.ts +5 -0
- package/lib/typescript/src/native/EngineBridge.d.ts +7 -1
- package/lib/typescript/src/native/spec/NativeNayanEngine.d.ts +72 -0
- package/lib/typescript/src/render/GameView.d.ts +30 -3
- package/lib/typescript/src/render/quality.d.ts +54 -2
- package/lib/typescript/src/types.d.ts +9 -2
- package/lib/typescript/src/world/World.d.ts +16 -0
- package/lib/typescript/src/world/types.d.ts +8 -0
- package/lib/web/audio-worklet.js +27 -0
- package/lib/web/engine_core_bg.wasm +0 -0
- package/lib/web/index.js +1181 -422
- package/lib/web/types/src/assets/ProceduralCache.d.ts +2 -0
- package/lib/web/types/src/assets/loft.d.ts +3 -1
- package/lib/web/types/src/assets/procedural.d.ts +393 -0
- package/lib/web/types/src/assets/shape.d.ts +13 -7
- package/lib/web/types/src/assets/texture.d.ts +63 -0
- package/lib/web/types/src/index.d.ts +8 -2
- package/lib/web/types/src/media/audio.d.ts +13 -0
- package/lib/web/types/src/media/input.d.ts +3 -0
- package/lib/web/types/src/media/microphone.d.ts +50 -0
- package/lib/web/types/src/media/speech.d.ts +37 -0
- package/lib/web/types/src/media/speechPlatform.d.ts +4 -0
- package/lib/web/types/src/native/EngineBridge.d.ts +7 -1
- package/lib/web/types/src/native/spec/NativeNayanEngine.d.ts +72 -0
- package/lib/web/types/src/render/GameView.d.ts +30 -3
- package/lib/web/types/src/render/quality.d.ts +54 -2
- package/lib/web/types/src/types.d.ts +9 -2
- package/lib/web/types/src/world/World.d.ts +16 -0
- package/lib/web/types/src/world/types.d.ts +8 -0
- package/native/android/src/main/AndroidManifest.xml +10 -0
- package/native/android/src/main/java/com/nayanui/engine/EngineMicrophone.java +60 -0
- package/native/android/src/main/java/com/nayanui/engine/EngineSpeech.java +28 -0
- package/native/android/src/main/java/com/nayanui/engine/EngineSurfaceView.java +1 -0
- package/native/android/src/main/java/com/nayanui/engine/NayanEnginePackage.java +2 -0
- package/native/core/build/EngineCore.xcframework/ios-arm64/libengine_core.a +0 -0
- package/native/core/build/EngineCore.xcframework/ios-arm64-simulator/libengine_core.a +0 -0
- package/native/core/build/android/arm64-v8a/libengine_core.a +0 -0
- package/native/core/build/android/armeabi-v7a/libengine_core.a +0 -0
- package/native/core/build/android/x86/libengine_core.a +0 -0
- package/native/core/build/android/x86_64/libengine_core.a +0 -0
- package/native/core/include/engine_core.h +63 -1
- package/native/cpp/NayanEngineModule.cpp +296 -0
- package/native/cpp/NayanEngineModule.h +40 -0
- package/native/cpp/generated/NayanEngineSpecJSI.h +397 -0
- package/package.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
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 *
|
|
229
|
-
f[22] = Math.round(height *
|
|
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
|
}
|