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

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 (71) hide show
  1. package/README.md +229 -113
  2. package/lib/module/assets/AssetLoader.js +1 -1
  3. package/lib/module/assets/ProceduralCache.js +31 -4
  4. package/lib/module/assets/font.js +10 -7
  5. package/lib/module/assets/model.js +41 -19
  6. package/lib/module/assets/storage.shared.js +2 -0
  7. package/lib/module/assets/storage.web.js +1 -0
  8. package/lib/module/assets/texture.js +14 -11
  9. package/lib/module/index.js +4 -1
  10. package/lib/module/media/audio.js +6 -3
  11. package/lib/module/native/BrowserEngine.js +17 -0
  12. package/lib/module/render/GameView.js +86 -18
  13. package/lib/module/render/light.js +24 -0
  14. package/lib/module/render/quality.js +12 -5
  15. package/lib/module/render/useWorld.js +72 -0
  16. package/lib/module/world/Scoreboard.js +21 -3
  17. package/lib/module/world/TransformBatch.js +5 -3
  18. package/lib/module/world/World.js +27 -13
  19. package/lib/module/world/color.js +80 -0
  20. package/lib/module/world/desc.js +6 -5
  21. package/lib/module/world/effects.js +20 -8
  22. package/lib/typescript/src/assets/AssetLoader.d.ts +1 -1
  23. package/lib/typescript/src/assets/ProceduralCache.d.ts +12 -0
  24. package/lib/typescript/src/assets/model.d.ts +10 -0
  25. package/lib/typescript/src/index.d.ts +4 -1
  26. package/lib/typescript/src/native/EngineBridge.d.ts +6 -2
  27. package/lib/typescript/src/native/spec/NativeNayanEngine.d.ts +29 -0
  28. package/lib/typescript/src/render/GameView.d.ts +19 -7
  29. package/lib/typescript/src/render/light.d.ts +12 -0
  30. package/lib/typescript/src/render/quality.d.ts +4 -2
  31. package/lib/typescript/src/render/useWorld.d.ts +20 -0
  32. package/lib/typescript/src/types.d.ts +13 -4
  33. package/lib/typescript/src/world/Scoreboard.d.ts +12 -2
  34. package/lib/typescript/src/world/TransformBatch.d.ts +2 -2
  35. package/lib/typescript/src/world/World.d.ts +10 -2
  36. package/lib/typescript/src/world/color.d.ts +18 -0
  37. package/lib/typescript/src/world/effects.d.ts +13 -12
  38. package/lib/typescript/src/world/types.d.ts +11 -6
  39. package/lib/web/engine_core_bg.wasm +0 -0
  40. package/lib/web/index.js +521 -255
  41. package/lib/web/types/src/assets/AssetLoader.d.ts +1 -1
  42. package/lib/web/types/src/assets/ProceduralCache.d.ts +12 -0
  43. package/lib/web/types/src/assets/model.d.ts +10 -0
  44. package/lib/web/types/src/index.d.ts +4 -1
  45. package/lib/web/types/src/native/EngineBridge.d.ts +6 -2
  46. package/lib/web/types/src/native/spec/NativeNayanEngine.d.ts +29 -0
  47. package/lib/web/types/src/render/GameView.d.ts +19 -7
  48. package/lib/web/types/src/render/light.d.ts +11 -0
  49. package/lib/web/types/src/render/quality.d.ts +4 -2
  50. package/lib/web/types/src/render/useWorld.d.ts +19 -0
  51. package/lib/web/types/src/types.d.ts +13 -4
  52. package/lib/web/types/src/world/Scoreboard.d.ts +12 -2
  53. package/lib/web/types/src/world/TransformBatch.d.ts +2 -2
  54. package/lib/web/types/src/world/World.d.ts +10 -2
  55. package/lib/web/types/src/world/color.d.ts +17 -0
  56. package/lib/web/types/src/world/effects.d.ts +13 -12
  57. package/lib/web/types/src/world/types.d.ts +11 -6
  58. package/native/android/src/main/java/com/nayanui/engine/EngineSurfaceView.java +87 -26
  59. package/native/core/build/EngineCore.xcframework/ios-arm64/libengine_core.a +0 -0
  60. package/native/core/build/EngineCore.xcframework/ios-arm64-simulator/libengine_core.a +0 -0
  61. package/native/core/build/android/arm64-v8a/libengine_core.a +0 -0
  62. package/native/core/build/android/armeabi-v7a/libengine_core.a +0 -0
  63. package/native/core/build/android/x86/libengine_core.a +0 -0
  64. package/native/core/build/android/x86_64/libengine_core.a +0 -0
  65. package/native/core/include/engine_core.h +9 -0
  66. package/native/cpp/NayanEngineModule.cpp +171 -0
  67. package/native/cpp/NayanEngineModule.h +30 -0
  68. package/native/cpp/generated/NayanEngineSpecJSI.h +105 -0
  69. package/native/ios/NayanEngineSurface.mm +30 -1
  70. package/package.json +2 -2
  71. package/src/native/spec/NativeNayanEngine.ts +39 -0
package/README.md CHANGED
@@ -1,14 +1,37 @@
1
1
  # @nayan-ui/engine
2
2
 
3
- A fast 3D game engine for **React** and **React Native**. One TypeScript API, one Rust core: rendering (wgpu), physics (Rapier), animation, particles, audio, haptics and 2D drawing.
3
+ [![npm](https://img.shields.io/npm/v/@nayan-ui/engine?label=npm)](https://www.npmjs.com/package/@nayan-ui/engine) [![license](https://img.shields.io/npm/l/@nayan-ui/engine)](#license)
4
4
 
5
- **[Documentation](https://www.nayanui.com/engine)** · **[First game tutorial](https://www.nayanui.com/engine/first-game)** · **[API reference](https://www.nayanui.com/engine/api-reference)** · **[Guide for AI agents](https://www.nayanui.com/engine/agent-guide)**
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
- | Platform | Renders with | Needs |
8
- | ----------- | ------------------------------ | ------------------------------------------------ |
9
- | iOS | Metal | A development or store build (not Expo Go) |
10
- | Android | Vulkan or OpenGL ES | `minSdkVersion` 26, a development or store build |
11
- | Web (React) | WebAssembly + WebGPU or WebGL2 | A modern browser over HTTPS or localhost |
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)** · **[Prebuilt games](https://www.nayanui.com/games)**
8
+
9
+ ## Contents
10
+
11
+ - [Features](#features)
12
+ - [Install](#install)
13
+ - [Quick start](#quick-start)
14
+ - [Core concepts](#core-concepts)
15
+ - [Guides](#guides): [Entities](#entities) · [Game loop](#game-loop) · [Physics](#physics) · [Animation](#animation) · [Characters](#characters) · [Effects](#effects) · [Input](#input) · [Assets](#assets) · [Sound and haptics](#sound-and-haptics) · [Score and timers](#score-and-timers) · [Shapes in code](#shapes-in-code) · [Flat boards](#flat-boards-and-puzzles) · [Drawing](#drawing) · [Colors](#colors)
16
+ - [Performance](#performance)
17
+ - [Troubleshooting](#troubleshooting)
18
+ - [Prebuilt games](#prebuilt-games)
19
+ - [Learn more](#learn-more)
20
+
21
+ ## Features
22
+
23
+ - **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.
24
+ - **Small API.** `useWorld` + `GameView` is a complete scene. Spawn entities, move them, animate them: the engine steps, draws and cleans up.
25
+ - **Fast loading.** Images, glTF models, fonts and sounds decode on native background threads, and procedural meshes can be cached on disk between launches.
26
+ - **Same code on the web.** The core compiles to WebAssembly and draws with WebGPU, falling back to WebGL2.
27
+
28
+ ### Platforms
29
+
30
+ | Platform | Renders with | Needs |
31
+ | ----------- | ------------------------------ | --------------------------------------------------------- |
32
+ | iOS | Metal | A development or store build (Expo Go doesn't include it) |
33
+ | Android | Vulkan or OpenGL ES | `minSdkVersion` 26, a development or store build |
34
+ | Web (React) | WebAssembly + WebGPU or WebGL2 | A modern browser over HTTPS or localhost |
12
35
 
13
36
  ## Install
14
37
 
@@ -16,138 +39,142 @@ A fast 3D game engine for **React** and **React Native**. One TypeScript API, on
16
39
  npm install @nayan-ui/engine
17
40
  ```
18
41
 
19
- **React Native (Expo):** add the model extensions to `metro.config.js`, then make a development build (`npx expo run:ios` / `npx expo run:android`). Install `expo-asset` if you load bundled files with `require(...)`.
42
+ Peer dependencies: `react`, plus `react-native` for apps. `expo-asset` is optional and only needed to load bundled files with `require(...)` in Expo.
43
+
44
+ ### React Native (Expo)
45
+
46
+ 1. Let Metro bundle 3D models, in `metro.config.js`:
47
+
48
+ ```js
49
+ config.resolver.assetExts.push('glb', 'gltf');
50
+ ```
51
+
52
+ 2. Target Android 8.0 (API 26) or newer (with `expo-build-properties`), and allow vibration for haptics, in `app.json`:
53
+
54
+ ```json
55
+ {
56
+ "expo": {
57
+ "android": { "permissions": ["android.permission.VIBRATE"] },
58
+ "plugins": [["expo-build-properties", { "android": { "minSdkVersion": 26 } }]]
59
+ }
60
+ }
61
+ ```
62
+
63
+ 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.
20
64
 
21
- ```js
22
- config.resolver.assetExts.push('glb', 'gltf');
65
+ Bare React Native apps autolink the same way: run `pod install` in `ios/` and set `minSdkVersion` 26 in Gradle.
66
+
67
+ Guard screens for builds without the native module (for example Expo Go) with `isEngineAvailable`:
68
+
69
+ ```tsx
70
+ import { isEngineAvailable } from '@nayan-ui/engine';
71
+
72
+ if (!isEngineAvailable) return <Text>This screen needs a development build.</Text>;
23
73
  ```
24
74
 
25
- **React (web):** nothing else to configure. Call `await warmUpEngine()` before creating a world.
75
+ ### React (web)
76
+
77
+ Nothing to configure. `useWorld` loads the WebAssembly core for you; if you create worlds yourself, `await warmUpEngine()` first.
26
78
 
27
79
  ## Quick start
28
80
 
29
- A complete scene that works unchanged on both platforms:
81
+ A complete scene that works unchanged on every platform:
30
82
 
31
83
  ```tsx
32
- import { useEffect, useState } from 'react';
33
- import { type Camera, GameView, warmUpEngine, World } from '@nayan-ui/engine';
34
-
35
- const camera: Camera = { eye: [0, 4, 8], target: [0, 0, 0], fov: 1 };
36
-
37
- export function SpinningCube() {
38
- const [world, setWorld] = useState<World | null>(null);
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
- );
84
+ import { GameView, useWorld } from '@nayan-ui/engine';
85
+
86
+ export function BouncingBall() {
87
+ const world = useWorld(100, (w) => {
88
+ w.spawn({ mesh: 'plane', scale: [20, 1, 20], color: '#4C9A4C', physics: 'fixed' });
89
+ w.spawn({ mesh: 'sphere', position: [0, 5, 0], color: '#3399FF', physics: { type: 'dynamic', bounce: 0.6 } });
90
+ });
91
+ return world && <GameView source={world} camera={{ eye: [0, 4, 8], target: [0, 0, 0], fov: 1 }} style={{ flex: 1 }} />;
67
92
  }
68
93
  ```
69
94
 
70
- ## The five rules
95
+ `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.
96
+
97
+ ## Core concepts
71
98
 
72
- 1. **Create** the world in an effect (after `await warmUpEngine()` on the web) and **dispose** it on unmount.
73
- 2. **Step** it from one place: `world.update(dt)` inside `GameView`'s `onUpdate`.
99
+ 1. **Own** a world with `useWorld(capacity, setup)`, or create it yourself after `await warmUpEngine()` and `dispose()` it on unmount.
100
+ 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
101
  3. **Keep handles**: `spawn` returns an `Entity`; change it with `set`, remove it with `despawn`.
75
- 4. **Batch per-frame moves** in `world.transforms`, then `flush()` once after `update`.
102
+ 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
103
  5. **Don't track disposal yourself**: check `world.isDisposed`. Cleanup calls are safe no-ops after `dispose()`.
77
104
 
78
- ## Common tasks
105
+ ## Guides
106
+
107
+ Each snippet assumes a `world` from `useWorld`.
108
+
109
+ ### Entities
110
+
111
+ ```ts
112
+ const box = world.spawn({ mesh: 'cube', position: [0, 1, 0], color: '#FF4757' });
113
+ world.set(box, { color: '#2ED573', scale: 2 }); // only the options you pass change
114
+ world.despawn(box);
115
+ ```
116
+
117
+ Built-in meshes include `'cube'`, `'sphere'`, `'plane'` and `'cylinder'`.
118
+
119
+ ### Game loop
120
+
121
+ ```tsx
122
+ <GameView
123
+ source={world}
124
+ onUpdate={(dt) => {
125
+ world.transforms.set(player, { position: [x, 0, z] }); // batched, applied as the world steps
126
+ }}
127
+ />
128
+ ```
79
129
 
80
- **Physics and collisions**
130
+ ### Physics
81
131
 
82
132
  ```ts
83
133
  const ball = world.spawn({ mesh: 'sphere', position: [0, 5, 0], physics: { type: 'dynamic', bounce: 0.6 } });
84
134
  const coin = world.spawn({ mesh: 'cylinder', position: [2, 1, 0], physics: { type: 'fixed', sensor: true } });
135
+
85
136
  world.impulse(ball, [0, 4, 0]);
86
137
  world.forEachCollision((a, b, { started }) => {
87
138
  if (started && (a === coin || b === coin)) world.despawn(coin);
88
139
  });
89
140
  ```
90
141
 
91
- **Animation** (runs in Rust and returns a Promise)
142
+ ### Animation
143
+
144
+ Tweens run in Rust and return a Promise.
92
145
 
93
146
  ```ts
94
- await world.animate(piece, { position: [2, 0, 3] }, { duration: 0.25, easing: 'back' });
147
+ await world.animate(box, { position: [2, 0, 3] }, { duration: 0.25, easing: 'back' });
95
148
  world.animate(star, { scale: 1.2 }, { repeat: 'forever', yoyo: true });
96
149
  ```
97
150
 
98
- **Ready-made effects**
151
+ ### Characters
152
+
153
+ glTF clips play natively, with crossfades.
99
154
 
100
155
  ```ts
101
- import { effects } from '@nayan-ui/engine';
156
+ const hero = await loadModel(require('./hero.glb'));
157
+ const player = world.spawn({ mesh: hero });
102
158
 
103
- effects.confetti(world, {
104
- position: [0, 2, 0],
105
- colors: [
106
- [1, 0.3, 0.3],
107
- [1, 0.85, 0.2]
108
- ],
109
- size: 0.12,
110
- speed: 6,
111
- count: 40,
112
- lifetime: 1.2,
113
- gravity: -6
114
- });
115
- 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: [1, 0.3, 0.3] }); // swell, flash white, shrink away
117
- world.despawn(piece);
159
+ world.playClip(player, 'idle');
160
+ world.playClip(player, 'run', { fade: 0.2 });
161
+ world.stopClip(player);
118
162
  ```
119
163
 
120
- Also available: `sparkle`, `shatter`, `splash`, `sparks`, `explosion` and `flash`.
164
+ Clip names are in `hero.animations`. `world.sampleSkinnedVertices` reads chosen vertices of the next pose (for planting feet on uneven ground).
121
165
 
122
- **Per-frame movement without allocations**
166
+ ### Effects
123
167
 
124
168
  ```ts
125
- import { type MutableQuat, quat } from '@nayan-ui/engine';
126
-
127
- const turn: MutableQuat = [0, 0, 0, 1];
128
- const at: [number, number, number] = [0, 0, 0];
169
+ import { effects } from '@nayan-ui/engine';
129
170
 
130
- function onUpdate(dt: number) {
131
- world.update(dt);
132
- world.position(player, at); // queries can fill an `out` array
133
- 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
136
- }
171
+ effects.confetti(world, { position: [0, 2, 0], colors: ['#FF4D4D', '#FFD933'], count: 40 });
172
+ await effects.pop(world, box, { scale: 1, color: '#FF4D4D' }); // swell, flash, shrink away
137
173
  ```
138
174
 
139
- **Score, lives and timers**
140
-
141
- ```ts
142
- world.scoreboard.handlers = { onScore: setScore, onTimeUp: () => endGame('lose') };
143
- world.scoreboard.reset(3, 60); // 3 lives and a stopped 60 s countdown for the ready screen
144
- world.scoreboard.resume(); // Start
145
- world.scoreboard.add(10);
146
- const timer = world.after(0.5, spawnWave); // game time: pauses with the game
147
- world.cancelTimer(timer);
148
- ```
175
+ Also available: `dust`, `sparkle`, `shatter`, `splash`, `sparks`, `explosion` and `flash`.
149
176
 
150
- **Input**
177
+ ### Input
151
178
 
152
179
  ```tsx
153
180
  <GameView
@@ -157,55 +184,144 @@ world.cancelTimer(timer);
157
184
  if (hit) world.animate(hit.entity, { scale: 1.2 }, { yoyo: true, repeat: 1 });
158
185
  }}
159
186
  onSwipe={(direction) => move(direction)}
160
- onDrag={({ phase, x, y }) => drag(phase, x, y)}
161
187
  />
162
188
  ```
163
189
 
164
- **Models, textures, text, sound and haptics**
190
+ Also available: `onDrag`, `onLongPress`, `onPinch`, the on-screen `Joystick` and `createKeyboardInput` (web).
191
+
192
+ ### Assets
165
193
 
166
194
  ```ts
167
- import { audio, haptics, loadFont, loadModel, loadTexture } from '@nayan-ui/engine';
195
+ import { loadFont, loadModel, loadTexture } from '@nayan-ui/engine';
168
196
 
169
- const car = await loadModel(require('./car.glb'), { fit: 2 }); // or a URL string on the web
197
+ const car = await loadModel(require('./car.glb'), { fit: 2 }); // or a URL on the web
170
198
  const crate = await loadTexture(require('./crate.png'));
171
199
  const font = await loadFont(require('./Inter-Bold.ttf'));
172
- world.spawn({ mesh: car, position: [0, 0, 0] });
200
+
201
+ world.spawn({ mesh: car });
173
202
  world.spawn({ mesh: 'cube', texture: crate });
174
203
  world.spawn({ text: '2048', font, position: [0, 2, 0], scale: 0.5 });
204
+ ```
205
+
206
+ ### Sound and haptics
207
+
208
+ ```ts
209
+ import { audio, haptics } from '@nayan-ui/engine';
175
210
 
176
211
  const sfx = audio.load({ coin: require('./coin.wav') });
177
212
  sfx.play('coin');
178
213
  haptics.impact(0.5);
214
+
179
215
  audio.enabled = settings.sounds; // respect the player's preferences
180
216
  haptics.enabled = settings.haptics;
181
217
  ```
182
218
 
183
- **Drawing**: a native paint canvas, also usable as a live texture inside games
219
+ ### Score and timers
220
+
221
+ ```ts
222
+ world.scoreboard.handlers = { onTimeUp: () => endGame('lose') };
223
+ world.scoreboard.listen({ onScore: setScore, onSecond: setTime }); // keep your UI in sync
224
+ world.scoreboard.reset(3, 60); // 3 lives, 60 s countdown
225
+ world.scoreboard.resume();
226
+ world.scoreboard.add(10);
227
+
228
+ const timer = world.after(0.5, spawnWave); // game time: pauses with the game
229
+ world.cancelTimer(timer);
230
+ ```
231
+
232
+ ### Shapes in code
233
+
234
+ Build a mesh once at module scope (after `warmUpEngine()`), then spawn it many times.
235
+
236
+ ```ts
237
+ import { createShape, lathe } from '@nayan-ui/engine';
238
+
239
+ const vase = createShape(lathe([[0, 0], [0.4, 0.1], [0.25, 0.8], [0.3, 1]], { segments: 24 }));
240
+ world.spawn({ mesh: vase, color: '#C8763A' });
241
+ ```
242
+
243
+ Also available: `merge`, `tube`, `extrude`, `loft`, `block`, `ellipsoid`, `parametricSurface`, `Solid` and `shapeGeometry`.
244
+
245
+ ### Flat boards and puzzles
246
+
247
+ `frontLight` keeps camera-facing surfaces at their exact color.
248
+
249
+ ```tsx
250
+ <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)} />
251
+ ```
252
+
253
+ ### Drawing
254
+
255
+ A native paint canvas, also usable as a live texture inside games.
184
256
 
185
257
  ```tsx
186
258
  import { DrawingCanvas, DrawingDocument } from '@nayan-ui/engine';
187
259
 
188
260
  const paper = new DrawingDocument(512, 512);
189
261
  paper.setBrush({ brushType: 'MARKER', color: '#e53935', width: 12, opacity: 1 });
262
+
190
263
  <DrawingCanvas document={paper} mode="draw" />;
191
264
  world.spawn({ mesh: 'plane', texture: paper.texture() }); // paint on a 3D object
192
265
  ```
193
266
 
267
+ Drawings save as SVG paths and export transparent PNG snapshots.
268
+
269
+ ### Colors
270
+
271
+ Use CSS strings (`'#FF4757'`, `'rgba(0, 0, 0, 0.5)'`) or linear 0..1 tuples (`[1, 0.3, 0.3]`; channels above 1 glow). `toColor('#FF4757')` converts a CSS color to numbers.
272
+
273
+ ## Performance
274
+
275
+ The engine is built so that a game written the obvious way stays smooth on mid-range Android phones.
276
+
277
+ - **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.
278
+ - **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.
279
+ - **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.
280
+ - **Background loading.** `loadTexture`, `loadModel`, `loadFont` and `audio.load` decode on native threads in parallel, and meshes are registered with the renderer inside Rust.
281
+ - **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.
282
+ - **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.
283
+
284
+ Keep these habits:
285
+
286
+ - 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.
287
+ - Move many things each frame through `world.transforms` and the `out` arguments of queries, which allocate nothing.
288
+ - Keep long one-off jobs out of gameplay frames: a single slow frame stalls Android's Hermes far longer than desktop JavaScript suggests.
289
+
290
+ ## Troubleshooting
291
+
292
+ | Symptom | Fix |
293
+ | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
294
+ | `isEngineAvailable` is `false` | You're in Expo Go or a build made before installing the engine: make a new development build. |
295
+ | `.glb` files fail to bundle | Add `glb` and `gltf` to Metro's `assetExts`. |
296
+ | Android build fails on `minSdkVersion` | Set it to 26 or higher. |
297
+ | An Android screenshot of the game view is blank | The engine draws into a `SurfaceView`: use `captureScreen(..., { handleGLSurfaceViewOnAndroid: true })`. |
298
+ | Out of mesh ids | A mesh is being created per spawn or per frame: build it once and reuse it (`getMeshIdLimit()`). |
299
+
300
+ ## Prebuilt games
301
+
302
+ [@nayan-ui/games](https://www.nayanui.com/games) offers 53 ready-made 3D games built on this engine. Each is one component with the same props.
303
+
304
+ `@nayan-ui/games` is a **private package**. To use it in your application or website, reach out to [niranjan.devasani@gmail.com](mailto:niranjan.devasani@gmail.com).
305
+
306
+ **[Play them in your browser](https://www.nayanui.com/games)**
307
+
194
308
  ## Learn more
195
309
 
196
310
  | Topic | Guide |
197
311
  | ------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
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)**.
312
+ | Build a complete game step by step | [First game](https://www.nayanui.com/game-engine/first-game) |
313
+ | Copy-paste solutions | [Recipes](https://www.nayanui.com/game-engine/recipes) |
314
+ | Worlds, entities, rotations, disposal | [World & entities](https://www.nayanui.com/game-engine/world-entities) |
315
+ | Effects and particles | [Effects](https://www.nayanui.com/game-engine/particles) |
316
+ | Procedural meshes and modelling | [Geometry](https://www.nayanui.com/game-engine/procedural-geometry) |
317
+ | Drawing and painting | [Drawing](https://www.nayanui.com/game-engine/drawing) |
318
+ | Performance and caching | [Performance](https://www.nayanui.com/game-engine/cache-performance) |
319
+ | Coding agents | [Agent guide](https://www.nayanui.com/game-engine/agent-guide) · [engine.json](https://www.nayanui.com/engine.json) |
208
320
 
209
321
  ## License
210
322
 
211
- MIT © [Niranjan Devasani](https://github.com/ursnj)
323
+ MIT
324
+
325
+ ---
326
+
327
+ 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
- view.setUint32(12, checksum(bytes.subarray(16)), true);
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
- for (const value of vertices) if (!Number.isFinite(value)) throw Error('cache vertex');
182
- for (const index of indices) if (index >= mesh.floats / 11) throw Error('cache index');
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
- import { registerMesh } from "../render/meshes.js";
4
+
5
5
  import { AssetLoader } from "./AssetLoader.js";
6
- import { nativeGeometry } from "./model.js";
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
- const id = native.fontLoad(bytes, depth, chars, weight);
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
- registerMesh(mesh, {
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
- }, [depth, weight]);
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
- registerMesh(id, {
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
- const mesh = native.modelLoad(bytes, center, fit);
57
- if (mesh < 0) throw new Error(native.loadError() || 'not a glTF model');
58
- const texture = native.modelTexture(mesh);
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}`);