@nayan-ui/engine 1.0.0-beta.3 → 1.0.0-beta.4
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 +167 -69
- package/lib/module/assets/AssetLoader.js +1 -1
- package/lib/module/assets/ProceduralCache.js +31 -4
- package/lib/module/assets/font.js +10 -7
- package/lib/module/assets/model.js +41 -19
- package/lib/module/assets/storage.shared.js +2 -0
- package/lib/module/assets/storage.web.js +1 -0
- package/lib/module/assets/texture.js +14 -11
- package/lib/module/index.js +4 -1
- package/lib/module/media/audio.js +6 -3
- package/lib/module/native/BrowserEngine.js +17 -0
- package/lib/module/render/GameView.js +86 -18
- package/lib/module/render/light.js +24 -0
- package/lib/module/render/quality.js +12 -5
- package/lib/module/render/useWorld.js +72 -0
- package/lib/module/world/Scoreboard.js +21 -3
- package/lib/module/world/TransformBatch.js +5 -3
- package/lib/module/world/World.js +27 -13
- package/lib/module/world/color.js +80 -0
- package/lib/module/world/desc.js +6 -5
- package/lib/module/world/effects.js +20 -8
- package/lib/typescript/src/assets/AssetLoader.d.ts +1 -1
- package/lib/typescript/src/assets/ProceduralCache.d.ts +12 -0
- package/lib/typescript/src/assets/model.d.ts +10 -0
- package/lib/typescript/src/index.d.ts +4 -1
- package/lib/typescript/src/native/EngineBridge.d.ts +6 -2
- package/lib/typescript/src/native/spec/NativeNayanEngine.d.ts +29 -0
- package/lib/typescript/src/render/GameView.d.ts +19 -7
- package/lib/typescript/src/render/light.d.ts +12 -0
- package/lib/typescript/src/render/quality.d.ts +4 -2
- package/lib/typescript/src/render/useWorld.d.ts +20 -0
- package/lib/typescript/src/types.d.ts +13 -4
- package/lib/typescript/src/world/Scoreboard.d.ts +12 -2
- package/lib/typescript/src/world/TransformBatch.d.ts +2 -2
- package/lib/typescript/src/world/World.d.ts +10 -2
- package/lib/typescript/src/world/color.d.ts +18 -0
- package/lib/typescript/src/world/effects.d.ts +13 -12
- package/lib/typescript/src/world/types.d.ts +11 -6
- package/lib/web/engine_core_bg.wasm +0 -0
- package/lib/web/index.js +521 -255
- package/lib/web/types/src/assets/AssetLoader.d.ts +1 -1
- package/lib/web/types/src/assets/ProceduralCache.d.ts +12 -0
- package/lib/web/types/src/assets/model.d.ts +10 -0
- package/lib/web/types/src/index.d.ts +4 -1
- package/lib/web/types/src/native/EngineBridge.d.ts +6 -2
- package/lib/web/types/src/native/spec/NativeNayanEngine.d.ts +29 -0
- package/lib/web/types/src/render/GameView.d.ts +19 -7
- package/lib/web/types/src/render/light.d.ts +11 -0
- package/lib/web/types/src/render/quality.d.ts +4 -2
- package/lib/web/types/src/render/useWorld.d.ts +19 -0
- package/lib/web/types/src/types.d.ts +13 -4
- package/lib/web/types/src/world/Scoreboard.d.ts +12 -2
- package/lib/web/types/src/world/TransformBatch.d.ts +2 -2
- package/lib/web/types/src/world/World.d.ts +10 -2
- package/lib/web/types/src/world/color.d.ts +17 -0
- package/lib/web/types/src/world/effects.d.ts +13 -12
- package/lib/web/types/src/world/types.d.ts +11 -6
- package/native/android/src/main/java/com/nayanui/engine/EngineSurfaceView.java +87 -26
- 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 +9 -0
- package/native/cpp/NayanEngineModule.cpp +171 -0
- package/native/cpp/NayanEngineModule.h +30 -0
- package/native/cpp/generated/NayanEngineSpecJSI.h +105 -0
- package/native/ios/NayanEngineSurface.mm +30 -1
- package/package.json +2 -2
- package/src/native/spec/NativeNayanEngine.ts +39 -0
package/README.md
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
# @nayan-ui/engine
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@nayan-ui/engine) [](#license)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
A fast 3D game engine for **React** and **React Native**. One TypeScript API on one Rust core: rendering (wgpu), physics (Rapier), skeletal and tween animation, particles, audio, haptics and 2D drawing.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
**[Documentation](https://www.nayanui.com/game-engine)** · **[First game tutorial](https://www.nayanui.com/game-engine/first-game)** · **[API reference](https://www.nayanui.com/game-engine/api-reference)** · **[Guide for AI agents](https://www.nayanui.com/game-engine/agent-guide)**
|
|
8
|
+
|
|
9
|
+
- **Native speed on phones.** Rendering, physics, animation, culling and audio mixing run in Rust. On iOS and Android the game loop follows the display's clock, and frames are drawn on the engine's own render thread, so your JavaScript never waits on the GPU.
|
|
10
|
+
- **Small API.** `useWorld` + `GameView` is a complete scene. Spawn entities, move them, animate them: the engine steps, draws and cleans up.
|
|
11
|
+
- **Fast loading.** Images, glTF models, fonts and sounds decode on native background threads, and procedural meshes can be cached on disk between launches.
|
|
12
|
+
- **Same code on the web.** The core compiles to WebAssembly and draws with WebGPU, falling back to WebGL2.
|
|
13
|
+
|
|
14
|
+
| Platform | Renders with | Needs |
|
|
15
|
+
| ----------- | ------------------------------ | --------------------------------------------------------- |
|
|
16
|
+
| iOS | Metal | A development or store build (Expo Go doesn't include it) |
|
|
17
|
+
| Android | Vulkan or OpenGL ES | `minSdkVersion` 26, a development or store build |
|
|
18
|
+
| Web (React) | WebAssembly + WebGPU or WebGL2 | A modern browser over HTTPS or localhost |
|
|
12
19
|
|
|
13
20
|
## Install
|
|
14
21
|
|
|
@@ -16,65 +23,86 @@ A fast 3D game engine for **React** and **React Native**. One TypeScript API, on
|
|
|
16
23
|
npm install @nayan-ui/engine
|
|
17
24
|
```
|
|
18
25
|
|
|
19
|
-
|
|
26
|
+
Peer dependencies: `react`, plus `react-native` for apps. `expo-asset` is optional and only needed to load bundled files with `require(...)` in Expo.
|
|
27
|
+
|
|
28
|
+
### React Native (Expo)
|
|
29
|
+
|
|
30
|
+
1. Let Metro bundle 3D models, in `metro.config.js`:
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
config.resolver.assetExts.push('glb', 'gltf');
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
2. Target Android 8.0 (API 26) or newer (with `expo-build-properties`), and allow vibration for haptics, in `app.json`:
|
|
20
37
|
|
|
21
|
-
```
|
|
22
|
-
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"expo": {
|
|
41
|
+
"android": { "permissions": ["android.permission.VIBRATE"] },
|
|
42
|
+
"plugins": [["expo-build-properties", { "android": { "minSdkVersion": 26 } }]]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
3. Make a development build: `npx expo run:ios` / `npx expo run:android` (or an EAS build). The engine ships prebuilt native libraries, so no Rust toolchain is needed.
|
|
48
|
+
|
|
49
|
+
Bare React Native apps autolink the same way: run `pod install` in `ios/` and set `minSdkVersion` 26 in Gradle.
|
|
50
|
+
|
|
51
|
+
Guard screens for builds without the native module (for example Expo Go) with `isEngineAvailable`:
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
import { isEngineAvailable } from '@nayan-ui/engine';
|
|
55
|
+
|
|
56
|
+
if (!isEngineAvailable) return <Text>This screen needs a development build.</Text>;
|
|
23
57
|
```
|
|
24
58
|
|
|
25
|
-
|
|
59
|
+
### React (web)
|
|
60
|
+
|
|
61
|
+
Nothing to configure. `useWorld` loads the WebAssembly core for you; if you create worlds yourself, `await warmUpEngine()` first.
|
|
26
62
|
|
|
27
63
|
## Quick start
|
|
28
64
|
|
|
29
|
-
A complete scene that works unchanged on
|
|
65
|
+
A complete scene that works unchanged on every platform:
|
|
30
66
|
|
|
31
67
|
```tsx
|
|
32
|
-
import {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
useEffect(() => {
|
|
41
|
-
let created: World | undefined;
|
|
42
|
-
let active = true;
|
|
43
|
-
void warmUpEngine().then(() => {
|
|
44
|
-
if (!active) return;
|
|
45
|
-
created = new World(100); // fixed entity capacity
|
|
46
|
-
created.spawn({ mesh: 'plane', scale: [20, 1, 20], color: [0.3, 0.6, 0.3], physics: 'fixed' });
|
|
47
|
-
const cube = created.spawn({ mesh: 'cube', position: [0, 3, 0], color: [0.2, 0.6, 1], physics: 'dynamic' });
|
|
48
|
-
created.animate(cube, { scale: 1.2 }, { duration: 0.6, repeat: 'forever', yoyo: true });
|
|
49
|
-
setWorld(created);
|
|
50
|
-
});
|
|
51
|
-
return () => {
|
|
52
|
-
active = false;
|
|
53
|
-
created?.dispose();
|
|
54
|
-
};
|
|
55
|
-
}, []);
|
|
56
|
-
|
|
57
|
-
if (!world) return null;
|
|
58
|
-
return (
|
|
59
|
-
<GameView
|
|
60
|
-
source={world}
|
|
61
|
-
camera={camera}
|
|
62
|
-
light={{ direction: [0.4, 0.8, 0.5], ambient: 0.35 }}
|
|
63
|
-
onUpdate={(dt) => world.update(dt)}
|
|
64
|
-
style={{ flex: 1 }}
|
|
65
|
-
/>
|
|
66
|
-
);
|
|
68
|
+
import { GameView, useWorld } from '@nayan-ui/engine';
|
|
69
|
+
|
|
70
|
+
export function BouncingBall() {
|
|
71
|
+
const world = useWorld(100, (w) => {
|
|
72
|
+
w.spawn({ mesh: 'plane', scale: [20, 1, 20], color: '#4C9A4C', physics: 'fixed' });
|
|
73
|
+
w.spawn({ mesh: 'sphere', position: [0, 5, 0], color: '#3399FF', physics: { type: 'dynamic', bounce: 0.6 } });
|
|
74
|
+
});
|
|
75
|
+
return world && <GameView source={world} camera={{ eye: [0, 4, 8], target: [0, 0, 0], fov: 1 }} style={{ flex: 1 }} />;
|
|
67
76
|
}
|
|
68
77
|
```
|
|
69
78
|
|
|
79
|
+
`useWorld` warms the engine up, creates the world (100 entity slots), runs your setup and disposes it on unmount. `GameView` draws it and steps it every frame.
|
|
80
|
+
|
|
70
81
|
## The five rules
|
|
71
82
|
|
|
72
|
-
1. **
|
|
73
|
-
2. **
|
|
83
|
+
1. **Own** a world with `useWorld(capacity, setup)`, or create it yourself after `await warmUpEngine()` and `dispose()` it on unmount.
|
|
84
|
+
2. **Play** in `GameView`'s `onUpdate(dt)`: read input, move things, spawn. The world is stepped for you right after (call `world.update(dt)` yourself only to step it earlier or with another `dt`).
|
|
74
85
|
3. **Keep handles**: `spawn` returns an `Entity`; change it with `set`, remove it with `despawn`.
|
|
75
|
-
4. **Batch per-frame moves** in `world.transforms
|
|
86
|
+
4. **Batch per-frame moves** in `world.transforms`: they are applied in one native call when the world steps (`flush()` yourself before reading positions).
|
|
76
87
|
5. **Don't track disposal yourself**: check `world.isDisposed`. Cleanup calls are safe no-ops after `dispose()`.
|
|
77
88
|
|
|
89
|
+
## Performance
|
|
90
|
+
|
|
91
|
+
The engine is built so that a game written the obvious way stays smooth on mid-range Android phones.
|
|
92
|
+
|
|
93
|
+
- **Display clock and render thread (iOS and Android).** The display's vsync (`CADisplayLink` on iOS, `Choreographer` on Android) asks for each frame at 60 Hz. `onUpdate` steps the game and hands a copy of the frame to a native render thread, which culls, submits and presents it. The JS thread never waits on the GPU, and a frame that is still waiting when a newer one arrives is replaced, not queued. The clock stops while the app is in the background or the view is off screen. On the web, `requestAnimationFrame` drives the same loop.
|
|
94
|
+
- **Culling in Rust.** Everything outside the camera's view (and outside the light's box for shadows) is skipped every frame, so open levels and long tracks only pay for what is on screen. Don't cull scenery in JS.
|
|
95
|
+
- **Automatic quality.** `quality="auto"` (the default) starts at `high` and steps down (shadow detail first, then anti-aliasing, resolution last) only while the device can't keep up. `onStats` reports fps, update and render time, the quality chosen and the graphics backend.
|
|
96
|
+
- **Background loading.** `loadTexture`, `loadModel`, `loadFont` and `audio.load` decode on native threads in parallel, and meshes are registered with the renderer inside Rust.
|
|
97
|
+
- **Work between frames.** `afterNextFrame(fn)` runs `fn` right after the next frame leaves, so you can spread long setup work (map generation, mesh building) over several frames behind a loading screen.
|
|
98
|
+
- **Cached procedural assets.** `ProceduralCache` saves built geometry on disk keyed by a recipe, so later launches restore it instead of rebuilding; `peek(key)` returns a saved value without building anything.
|
|
99
|
+
|
|
100
|
+
Keep these habits:
|
|
101
|
+
|
|
102
|
+
- Build meshes once, at module scope or behind a cache, and spawn new entities from them. Mesh ids are app-wide and never freed (`getMeshIdLimit()` tells you how many there are), so never create a mesh per spawn or per frame.
|
|
103
|
+
- Move many things each frame through `world.transforms` and the `out` arguments of queries, which allocate nothing.
|
|
104
|
+
- Keep long one-off jobs out of gameplay frames: a single slow frame stalls Android's Hermes far longer than desktop JavaScript suggests.
|
|
105
|
+
|
|
78
106
|
## Common tasks
|
|
79
107
|
|
|
80
108
|
**Physics and collisions**
|
|
@@ -95,6 +123,19 @@ await world.animate(piece, { position: [2, 0, 3] }, { duration: 0.25, easing: 'b
|
|
|
95
123
|
world.animate(star, { scale: 1.2 }, { repeat: 'forever', yoyo: true });
|
|
96
124
|
```
|
|
97
125
|
|
|
126
|
+
**Skinned characters**: glTF clips play natively, with crossfades
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
const hero = await loadModel(require('./hero.glb'));
|
|
130
|
+
console.log(hero.animations.map((clip) => clip.name)); // e.g. ['idle', 'walk', 'run']
|
|
131
|
+
const player = world.spawn({ mesh: hero });
|
|
132
|
+
world.playClip(player, 'idle', { speed: 0 }); // hold the first pose on the ready screen
|
|
133
|
+
world.playClip(player, 'run', { fade: 0.2 }); // crossfade when play starts
|
|
134
|
+
world.stopClip(player);
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`world.sampleSkinnedVertices` reads chosen vertices of the next pose (for planting feet on uneven ground) without skinning the whole character in JS.
|
|
138
|
+
|
|
98
139
|
**Ready-made effects**
|
|
99
140
|
|
|
100
141
|
```ts
|
|
@@ -102,10 +143,7 @@ import { effects } from '@nayan-ui/engine';
|
|
|
102
143
|
|
|
103
144
|
effects.confetti(world, {
|
|
104
145
|
position: [0, 2, 0],
|
|
105
|
-
colors: [
|
|
106
|
-
[1, 0.3, 0.3],
|
|
107
|
-
[1, 0.85, 0.2]
|
|
108
|
-
],
|
|
146
|
+
colors: ['#FF4D4D', '#FFD933'],
|
|
109
147
|
size: 0.12,
|
|
110
148
|
speed: 6,
|
|
111
149
|
count: 40,
|
|
@@ -113,7 +151,7 @@ effects.confetti(world, {
|
|
|
113
151
|
gravity: -6
|
|
114
152
|
});
|
|
115
153
|
effects.dust(world, { position: [0, 0, 0], color: [0.8, 0.75, 0.65, 0.6], size: 0.3, speed: 1.5, count: 6, lifetime: 0.5 });
|
|
116
|
-
await effects.pop(world, piece, { scale: 1, color:
|
|
154
|
+
await effects.pop(world, piece, { scale: 1, color: '#FF4D4D' }); // swell, flash white, shrink away
|
|
117
155
|
world.despawn(piece);
|
|
118
156
|
```
|
|
119
157
|
|
|
@@ -128,18 +166,54 @@ const turn: MutableQuat = [0, 0, 0, 1];
|
|
|
128
166
|
const at: [number, number, number] = [0, 0, 0];
|
|
129
167
|
|
|
130
168
|
function onUpdate(dt: number) {
|
|
131
|
-
world.update(dt);
|
|
132
169
|
world.position(player, at); // queries can fill an `out` array
|
|
133
170
|
quat.y(heading, turn);
|
|
134
|
-
world.transforms.transform(car, carX, 0, carZ, turn[0], turn[1], turn[2], turn[3]);
|
|
135
|
-
world.transforms.flush(); // one native call per frame
|
|
171
|
+
world.transforms.transform(car, carX, 0, carZ, turn[0], turn[1], turn[2], turn[3]); // applied in one native call as the world steps
|
|
136
172
|
}
|
|
137
173
|
```
|
|
138
174
|
|
|
175
|
+
**Models built in code**: bake a shape once, spawn it many times
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
import { createShape, lathe, merge, tube } from '@nayan-ui/engine';
|
|
179
|
+
|
|
180
|
+
// Built once per app (after warmUpEngine), at module scope.
|
|
181
|
+
const vase = createShape(
|
|
182
|
+
merge([
|
|
183
|
+
{
|
|
184
|
+
geometry: lathe(
|
|
185
|
+
[
|
|
186
|
+
[0, 0],
|
|
187
|
+
[0.4, 0.1],
|
|
188
|
+
[0.25, 0.8],
|
|
189
|
+
[0.3, 1]
|
|
190
|
+
],
|
|
191
|
+
{ segments: 24 }
|
|
192
|
+
)
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
geometry: tube(
|
|
196
|
+
[
|
|
197
|
+
[0.3, 0.9, 0],
|
|
198
|
+
[0.5, 0.7, 0],
|
|
199
|
+
[0.3, 0.5, 0]
|
|
200
|
+
],
|
|
201
|
+
{ radius: 0.04 }
|
|
202
|
+
),
|
|
203
|
+
color: [0.5, 0.25, 0.1]
|
|
204
|
+
} // part colors are 0..1 tuples
|
|
205
|
+
])
|
|
206
|
+
);
|
|
207
|
+
world.spawn({ mesh: vase, color: '#C8763A' });
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Also available: `extrude`, `loft`, `block`, `ellipsoid`, `parametricSurface`, `Solid` and `shapeGeometry` for the built-in shapes.
|
|
211
|
+
|
|
139
212
|
**Score, lives and timers**
|
|
140
213
|
|
|
141
214
|
```ts
|
|
142
|
-
world.scoreboard.handlers = {
|
|
215
|
+
world.scoreboard.handlers = { onTimeUp: () => endGame('lose') }; // the game's rules
|
|
216
|
+
const stopListening = world.scoreboard.listen({ onScore: setScore, onSecond: setTime }); // UI follows along
|
|
143
217
|
world.scoreboard.reset(3, 60); // 3 lives and a stopped 60 s countdown for the ready screen
|
|
144
218
|
world.scoreboard.resume(); // Start
|
|
145
219
|
world.scoreboard.add(10);
|
|
@@ -147,6 +221,12 @@ const timer = world.after(0.5, spawnWave); // game time: pauses with the game
|
|
|
147
221
|
world.cancelTimer(timer);
|
|
148
222
|
```
|
|
149
223
|
|
|
224
|
+
**Flat boards and puzzles**: `frontLight` keeps camera-facing surfaces at their exact color
|
|
225
|
+
|
|
226
|
+
```tsx
|
|
227
|
+
<GameView source={world} camera={{ eye: [0, 0, 50], target: [0, 0, 0], fov: 1, ortho: 10 }} light={frontLight([-0.4, 0.6, 1], 0.5)} />
|
|
228
|
+
```
|
|
229
|
+
|
|
150
230
|
**Input**
|
|
151
231
|
|
|
152
232
|
```tsx
|
|
@@ -161,6 +241,8 @@ world.cancelTimer(timer);
|
|
|
161
241
|
/>
|
|
162
242
|
```
|
|
163
243
|
|
|
244
|
+
`onLongPress`, `onPinch`, the on-screen `Joystick` and `createKeyboardInput` (web) cover other controls.
|
|
245
|
+
|
|
164
246
|
**Models, textures, text, sound and haptics**
|
|
165
247
|
|
|
166
248
|
```ts
|
|
@@ -180,6 +262,8 @@ audio.enabled = settings.sounds; // respect the player's preferences
|
|
|
180
262
|
haptics.enabled = settings.haptics;
|
|
181
263
|
```
|
|
182
264
|
|
|
265
|
+
**Colors** are CSS strings (`'#FF4757'`, `'rgba(0, 0, 0, 0.5)'`) or linear 0..1 tuples (`[1, 0.3, 0.3]`; channels above 1 glow) for entities, animations, particles, lights and the background. Vertex colors baked into geometry are tuples. `toColor('#FF4757')` converts a CSS color when you need the numbers.
|
|
266
|
+
|
|
183
267
|
**Drawing**: a native paint canvas, also usable as a live texture inside games
|
|
184
268
|
|
|
185
269
|
```tsx
|
|
@@ -191,21 +275,35 @@ paper.setBrush({ brushType: 'MARKER', color: '#e53935', width: 12, opacity: 1 })
|
|
|
191
275
|
world.spawn({ mesh: 'plane', texture: paper.texture() }); // paint on a 3D object
|
|
192
276
|
```
|
|
193
277
|
|
|
278
|
+
Drawings save as SVG paths and export transparent PNG snapshots.
|
|
279
|
+
|
|
280
|
+
## Troubleshooting
|
|
281
|
+
|
|
282
|
+
| Symptom | Fix |
|
|
283
|
+
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
284
|
+
| `isEngineAvailable` is `false` | You're in Expo Go or a build made before installing the engine: make a new development build. |
|
|
285
|
+
| `.glb` files fail to bundle | Add `glb` and `gltf` to Metro's `assetExts`. |
|
|
286
|
+
| Android build fails on `minSdkVersion` | Set it to 26 or higher. |
|
|
287
|
+
| An Android screenshot of the game view is blank | The engine draws into a `SurfaceView`: use `captureScreen(..., { handleGLSurfaceViewOnAndroid: true })`. |
|
|
288
|
+
| Out of mesh ids | A mesh is being created per spawn or per frame: build it once and reuse it (`getMeshIdLimit()`). |
|
|
289
|
+
|
|
194
290
|
## Learn more
|
|
195
291
|
|
|
196
292
|
| Topic | Guide |
|
|
197
293
|
| ------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
198
|
-
| Build a complete game step by step | [First game](https://www.nayanui.com/engine/first-game) |
|
|
199
|
-
| Copy-paste solutions | [Recipes](https://www.nayanui.com/engine/recipes) |
|
|
200
|
-
| Worlds, entities, rotations, disposal | [World & entities](https://www.nayanui.com/engine/world-entities) |
|
|
201
|
-
| Effects and particles | [Effects](https://www.nayanui.com/engine/particles) |
|
|
202
|
-
| Procedural meshes and modelling | [Geometry](https://www.nayanui.com/engine/procedural-geometry) |
|
|
203
|
-
| Drawing and painting | [Drawing](https://www.nayanui.com/engine/drawing) |
|
|
204
|
-
| Performance and caching | [Performance](https://www.nayanui.com/engine/cache-performance) |
|
|
205
|
-
| Coding agents | [Agent guide](https://www.nayanui.com/engine/agent-guide) · [engine.json](https://www.nayanui.com/engine.json) |
|
|
206
|
-
|
|
207
|
-
Ready-made games built on this engine: **[@nayan-ui/games](https://www.nayanui.com/games)**.
|
|
294
|
+
| Build a complete game step by step | [First game](https://www.nayanui.com/game-engine/first-game) |
|
|
295
|
+
| Copy-paste solutions | [Recipes](https://www.nayanui.com/game-engine/recipes) |
|
|
296
|
+
| Worlds, entities, rotations, disposal | [World & entities](https://www.nayanui.com/game-engine/world-entities) |
|
|
297
|
+
| Effects and particles | [Effects](https://www.nayanui.com/game-engine/particles) |
|
|
298
|
+
| Procedural meshes and modelling | [Geometry](https://www.nayanui.com/game-engine/procedural-geometry) |
|
|
299
|
+
| Drawing and painting | [Drawing](https://www.nayanui.com/game-engine/drawing) |
|
|
300
|
+
| Performance and caching | [Performance](https://www.nayanui.com/game-engine/cache-performance) |
|
|
301
|
+
| Coding agents | [Agent guide](https://www.nayanui.com/game-engine/agent-guide) · [engine.json](https://www.nayanui.com/engine.json) |
|
|
208
302
|
|
|
209
303
|
## License
|
|
210
304
|
|
|
211
305
|
MIT © [Niranjan Devasani](https://github.com/ursnj)
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
Made with ❤️ by [Niranjan Reddy Devasani](https://github.com/ursnj)
|
|
@@ -29,7 +29,7 @@ export class AssetLoader {
|
|
|
29
29
|
bytes
|
|
30
30
|
} = await loadAssetBytes(source);
|
|
31
31
|
try {
|
|
32
|
-
return decode(NativeEngine, bytes);
|
|
32
|
+
return await decode(NativeEngine, bytes);
|
|
33
33
|
} catch (cause) {
|
|
34
34
|
// Only rejected payloads invalidate saved bytes. Initialization/network failures don't.
|
|
35
35
|
forgetAssetBytes(uri);
|
|
@@ -55,6 +55,27 @@ export class ProceduralCache {
|
|
|
55
55
|
async flush() {
|
|
56
56
|
await Promise.all([...this.writes]);
|
|
57
57
|
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A value already cached (in memory, or saved by an earlier launch), without building anything:
|
|
61
|
+
* undefined when there is none yet. Lets work that's spread over many frames (a level generated
|
|
62
|
+
* step by step) skip itself entirely when a saved result exists, then save with `get`.
|
|
63
|
+
*/
|
|
64
|
+
peek(key) {
|
|
65
|
+
if (this.memory.has(key)) return this.memory.get(key);
|
|
66
|
+
if (!this.storage) return undefined;
|
|
67
|
+
try {
|
|
68
|
+
const bytes = this.storage.read(key);
|
|
69
|
+
if (!bytes) return undefined;
|
|
70
|
+
const value = this.restore(bytes, key);
|
|
71
|
+
this.memory.set(key, value);
|
|
72
|
+
this.hits++;
|
|
73
|
+
return value;
|
|
74
|
+
} catch {
|
|
75
|
+
this.invalid++;
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
58
79
|
get(key, build) {
|
|
59
80
|
if (this.memory.has(key)) return this.memory.get(key);
|
|
60
81
|
if (this.storage) {
|
|
@@ -158,7 +179,9 @@ export class ProceduralCache {
|
|
|
158
179
|
bytes.set(new Uint8Array(data.buffer, data.byteOffset, data.byteLength), offset);
|
|
159
180
|
offset += data.byteLength;
|
|
160
181
|
}
|
|
161
|
-
|
|
182
|
+
// A verifying store checksums the record itself: hashing every byte again in JS cost more than
|
|
183
|
+
// building small models.
|
|
184
|
+
view.setUint32(12, this.storage?.verified ? 0 : checksum(bytes.subarray(16)), true);
|
|
162
185
|
return bytes;
|
|
163
186
|
}
|
|
164
187
|
restore(raw, key) {
|
|
@@ -166,7 +189,7 @@ export class ProceduralCache {
|
|
|
166
189
|
const bytes = raw.byteOffset % 4 ? raw.slice() : raw;
|
|
167
190
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
168
191
|
const jsonLength = view.getUint32(4, true);
|
|
169
|
-
if (view.getUint32(0, true) !== MAGIC || view.getUint32(8, true) !== bytes.length || jsonLength > bytes.length - 16 || view.getUint32(12, true) !== checksum(bytes.subarray(16))) throw Error('cache integrity');
|
|
192
|
+
if (view.getUint32(0, true) !== MAGIC || view.getUint32(8, true) !== bytes.length || jsonLength > bytes.length - 16 || !this.storage?.verified && view.getUint32(12, true) !== checksum(bytes.subarray(16))) throw Error('cache integrity');
|
|
170
193
|
const header = decodeJson(bytes.subarray(16, 16 + jsonLength));
|
|
171
194
|
if (!('value' in header) || header.revision !== this.revision || header.key !== key || !Array.isArray(header.meshes)) throw Error('cache revision');
|
|
172
195
|
let offset = 16 + jsonLength + 3 & ~3;
|
|
@@ -178,8 +201,12 @@ export class ProceduralCache {
|
|
|
178
201
|
if (end > bytes.length) throw Error('cache truncated');
|
|
179
202
|
const vertices = new Float32Array(bytes.buffer, bytes.byteOffset + offset, mesh.floats);
|
|
180
203
|
const indices = new Uint32Array(bytes.buffer, bytes.byteOffset + offset + mesh.floats * 4, mesh.indices);
|
|
181
|
-
|
|
182
|
-
|
|
204
|
+
// A verified record is the one this cache wrote, and the core rejects bad geometry anyway:
|
|
205
|
+
// scanning millions of floats in JS was most of a relaunch's loading time.
|
|
206
|
+
if (!this.storage?.verified) {
|
|
207
|
+
for (const value of vertices) if (!Number.isFinite(value)) throw Error('cache vertex');
|
|
208
|
+
for (const index of indices) if (index >= mesh.floats / 11) throw Error('cache index');
|
|
209
|
+
}
|
|
183
210
|
payloads.push({
|
|
184
211
|
vertices,
|
|
185
212
|
indices,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
// Fonts for 3D text: each character becomes an extruded mesh, built by the Rust core.
|
|
4
|
-
|
|
4
|
+
|
|
5
5
|
import { AssetLoader } from "./AssetLoader.js";
|
|
6
|
-
import {
|
|
6
|
+
import { registerCoreMesh } from "./model.js";
|
|
7
7
|
/** Printable ASCII plus common arithmetic and typography, prewarmed for ordinary labels. */
|
|
8
8
|
export const DEFAULT_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz !"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~×÷±−°·•–—…‘’“”';
|
|
9
9
|
|
|
@@ -36,7 +36,12 @@ export function loadFont(source, options = {}) {
|
|
|
36
36
|
const chars = [...new Set(`${options.chars ?? DEFAULT_CHARS} ?`.normalize('NFC'))].filter(ch => !isInvisible(ch)).join('');
|
|
37
37
|
const weight = options.weight ?? 0;
|
|
38
38
|
const pending = loader.load(source, (native, bytes) => {
|
|
39
|
-
|
|
39
|
+
// Outlines and glyph meshes are built on a background native thread where the app's build supports it.
|
|
40
|
+
if (typeof native.fontLoadAsync === 'function') return native.fontLoadAsync(bytes, depth, chars, weight).then(id => finish(native, bytes, id));
|
|
41
|
+
return finish(native, bytes, native.fontLoad(bytes, depth, chars, weight));
|
|
42
|
+
}, [depth, weight]);
|
|
43
|
+
/** The loaded font's glyphs, registered for drawing in Rust, and its on-demand extension. */
|
|
44
|
+
function finish(native, bytes, id) {
|
|
40
45
|
if (id < 0) throw new Error(native.loadError() || 'not a TrueType / OpenType font');
|
|
41
46
|
const glyphs = new Map();
|
|
42
47
|
const registered = new Set();
|
|
@@ -45,9 +50,7 @@ export function loadFont(source, options = {}) {
|
|
|
45
50
|
for (let i = 0; i + 3 <= data.length - 1; i += 3) {
|
|
46
51
|
const mesh = data[i + 1];
|
|
47
52
|
if (mesh >= 0 && !registered.has(mesh)) {
|
|
48
|
-
|
|
49
|
-
geometry: nativeGeometry(mesh)
|
|
50
|
-
});
|
|
53
|
+
registerCoreMesh(mesh);
|
|
51
54
|
registered.add(mesh);
|
|
52
55
|
}
|
|
53
56
|
glyphs.set(String.fromCodePoint(data[i]), {
|
|
@@ -84,7 +87,7 @@ export function loadFont(source, options = {}) {
|
|
|
84
87
|
}
|
|
85
88
|
});
|
|
86
89
|
return font;
|
|
87
|
-
}
|
|
90
|
+
}
|
|
88
91
|
return pending.then(font => {
|
|
89
92
|
extend.get(font)?.(chars);
|
|
90
93
|
return font;
|
|
@@ -18,6 +18,24 @@ export function nativeGeometry(mesh) {
|
|
|
18
18
|
indices: new Uint32Array(NativeEngine.modelIndices(mesh))
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Registers a mesh the core already holds (a created, cached or loaded model, a glyph, an alias):
|
|
24
|
+
* the renderer copies it inside Rust, and JS reads its geometry only if something asks for it
|
|
25
|
+
* (merging, caching). Apps built before `renderModel` upload it through JS as before.
|
|
26
|
+
*/
|
|
27
|
+
export function registerCoreMesh(mesh, info = {}, geometry = () => nativeGeometry(mesh)) {
|
|
28
|
+
const native = NativeEngine;
|
|
29
|
+
let cached;
|
|
30
|
+
const inRust = typeof native.renderModel === 'function' && native.renderModel(mesh, info.texture ?? -1, info.shine ?? 0);
|
|
31
|
+
registerMesh(mesh, {
|
|
32
|
+
get geometry() {
|
|
33
|
+
return cached ??= geometry();
|
|
34
|
+
},
|
|
35
|
+
texture: info.texture,
|
|
36
|
+
shine: info.shine
|
|
37
|
+
}, !inRust);
|
|
38
|
+
}
|
|
21
39
|
const materials = new Map();
|
|
22
40
|
|
|
23
41
|
/** Independent materials share core geometry without overwriting an existing model's appearance. */
|
|
@@ -29,11 +47,10 @@ export function registerModelMesh(mesh, shine) {
|
|
|
29
47
|
if (cached !== undefined) return cached;
|
|
30
48
|
const id = info ? NativeEngine.meshAlias(mesh) : mesh;
|
|
31
49
|
if (id < 0) throw new Error(NativeEngine.loadError() || "couldn't register a material variant");
|
|
32
|
-
|
|
33
|
-
geometry: info?.geometry ?? nativeGeometry(id),
|
|
50
|
+
registerCoreMesh(id, {
|
|
34
51
|
texture: info?.texture,
|
|
35
52
|
shine
|
|
36
|
-
});
|
|
53
|
+
}, info ? () => info.geometry : undefined);
|
|
37
54
|
materials.set(key, id);
|
|
38
55
|
return id;
|
|
39
56
|
}
|
|
@@ -53,22 +70,27 @@ export function loadModel(source, options = {}) {
|
|
|
53
70
|
const fit = options.fit ?? 0;
|
|
54
71
|
const shine = Math.max(0, Math.min(1, options.shine ?? 0));
|
|
55
72
|
return loader.load(source, (native, bytes) => {
|
|
56
|
-
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
registerMesh(mesh, {
|
|
60
|
-
geometry: nativeGeometry(mesh),
|
|
61
|
-
texture: texture >= 0 ? texture : undefined,
|
|
62
|
-
shine
|
|
63
|
-
});
|
|
64
|
-
const [x, y, z] = native.modelSize(mesh);
|
|
65
|
-
const rig = JSON.parse(native.modelRig(mesh));
|
|
66
|
-
return {
|
|
67
|
-
animations: rig.clips,
|
|
68
|
-
jointCount: rig.joints,
|
|
69
|
-
id: mesh,
|
|
70
|
-
size: [x, y, z]
|
|
71
|
-
};
|
|
73
|
+
// Parsed on a background native thread where the app's build supports it.
|
|
74
|
+
if (typeof native.modelLoadAsync === 'function') return native.modelLoadAsync(bytes, center, fit).then(mesh => finishModel(native, mesh, shine));
|
|
75
|
+
return finishModel(native, native.modelLoad(bytes, center, fit), shine);
|
|
72
76
|
}, [center, fit, shine]);
|
|
73
77
|
}
|
|
78
|
+
|
|
79
|
+
/** A parsed glTF's handle: registered for drawing (in Rust) with its rig's clips. */
|
|
80
|
+
function finishModel(native, mesh, shine) {
|
|
81
|
+
if (mesh < 0) throw new Error(native.loadError() || 'not a glTF model');
|
|
82
|
+
const texture = native.modelTexture(mesh);
|
|
83
|
+
registerCoreMesh(mesh, {
|
|
84
|
+
texture: texture >= 0 ? texture : undefined,
|
|
85
|
+
shine
|
|
86
|
+
});
|
|
87
|
+
const [x, y, z] = native.modelSize(mesh);
|
|
88
|
+
const rig = JSON.parse(native.modelRig(mesh));
|
|
89
|
+
return {
|
|
90
|
+
animations: rig.clips,
|
|
91
|
+
jointCount: rig.joints,
|
|
92
|
+
id: mesh,
|
|
93
|
+
size: [x, y, z]
|
|
94
|
+
};
|
|
95
|
+
}
|
|
74
96
|
//# sourceMappingURL=model.js.map
|
|
@@ -5,6 +5,8 @@ import NativeEngine from '../native/NativeNayanEngine';
|
|
|
5
5
|
export function proceduralStorage(revision) {
|
|
6
6
|
const native = NativeEngine;
|
|
7
7
|
return {
|
|
8
|
+
// The Rust store checksums every record (SHA-256) and rejects any that don't match.
|
|
9
|
+
verified: true,
|
|
8
10
|
read(key) {
|
|
9
11
|
if (typeof native?.cacheRead !== 'function') return null;
|
|
10
12
|
const data = native.cacheRead(`procedural:${revision}:${key}`);
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import NativeEngine from '../native/NativeNayanEngine';
|
|
4
4
|
// Textures: PNG / JPEG images, decoded by the Rust core, drawn on built-in shapes or models.
|
|
5
|
-
import { meshInfo
|
|
5
|
+
import { meshInfo } from "../render/meshes.js";
|
|
6
6
|
import { AssetLoader } from "./AssetLoader.js";
|
|
7
|
+
import { registerCoreMesh } from "./model.js";
|
|
7
8
|
|
|
8
9
|
/** A loaded image. Pass it as an entity's `texture`. */
|
|
9
10
|
|
|
@@ -20,14 +21,17 @@ const loader = new AssetLoader('loadTexture');
|
|
|
20
21
|
*/
|
|
21
22
|
export function loadTexture(source) {
|
|
22
23
|
return loader.load(source, (native, bytes) => {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
const finish = id => {
|
|
25
|
+
if (id < 0) throw new Error(native.loadError() || 'not a PNG or JPEG image');
|
|
26
|
+
const [width, height] = native.textureSize(id);
|
|
27
|
+
return {
|
|
28
|
+
id,
|
|
29
|
+
width,
|
|
30
|
+
height
|
|
31
|
+
};
|
|
30
32
|
};
|
|
33
|
+
// Decoded on a background native thread where the app's build supports it.
|
|
34
|
+
return typeof native.textureLoadAsync === 'function' ? native.textureLoadAsync(bytes).then(finish) : finish(native.textureLoad(bytes));
|
|
31
35
|
});
|
|
32
36
|
}
|
|
33
37
|
|
|
@@ -54,11 +58,10 @@ export function texturedMesh(mesh, texture) {
|
|
|
54
58
|
if (!base) throw new Error('texture: this mesh has nothing to draw');
|
|
55
59
|
id = NativeEngine.meshAlias(mesh);
|
|
56
60
|
if (id < 0) throw new Error(`texture: ${NativeEngine.loadError()}`);
|
|
57
|
-
|
|
58
|
-
geometry: base.geometry,
|
|
61
|
+
registerCoreMesh(id, {
|
|
59
62
|
texture: texture.id,
|
|
60
63
|
shine: base.shine
|
|
61
|
-
});
|
|
64
|
+
}, () => base.geometry);
|
|
62
65
|
variants.set(key, id);
|
|
63
66
|
}
|
|
64
67
|
return id;
|
package/lib/module/index.js
CHANGED
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
// World: entities, physics, collisions, animation, particles, picking.
|
|
6
6
|
export { World, isEngineAvailable } from "./world/World.js";
|
|
7
7
|
export { quat } from "./world/quat.js";
|
|
8
|
+
export { toColor } from "./world/color.js";
|
|
8
9
|
export { effects } from "./world/effects.js";
|
|
9
10
|
export { Scoreboard } from "./world/Scoreboard.js";
|
|
10
11
|
// Rendering and touch.
|
|
11
|
-
export { GameView } from "./render/GameView.js";
|
|
12
|
+
export { afterNextFrame, GameView } from "./render/GameView.js";
|
|
13
|
+
export { useWorld } from "./render/useWorld.js";
|
|
14
|
+
export { frontLight } from "./render/light.js";
|
|
12
15
|
export { warmUpEngine, getMeshIdLimit } from "./render/meshes.js";
|
|
13
16
|
|
|
14
17
|
// Models, custom shapes, textures and fonts.
|
|
@@ -14,9 +14,12 @@ import NativeEngine from '../native/NativeNayanEngine';
|
|
|
14
14
|
const loader = new AssetLoader('audio');
|
|
15
15
|
function loadSound(source) {
|
|
16
16
|
return loader.load(source, (native, bytes) => {
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
const finish = id => {
|
|
18
|
+
if (id < 0) throw new Error(native.loadError() || 'not a supported WAV file');
|
|
19
|
+
return id;
|
|
20
|
+
};
|
|
21
|
+
// Decoded on a background native thread where the app's build supports it.
|
|
22
|
+
return typeof native.audioLoadAsync === 'function' ? native.audioLoadAsync(bytes).then(finish) : finish(native.audioLoad(bytes));
|
|
20
23
|
}).catch(error => {
|
|
21
24
|
console.warn(error instanceof Error ? error.message : String(error));
|
|
22
25
|
return null;
|