@ringozz/godot 4.7.1-7 → 4.7.1-9
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 +65 -4
- package/package.json +14 -5
- package/src/assets.d.ts +116 -0
- package/src/boot.browser.ts +39 -0
- package/src/boot.ts +26 -0
- package/src/debug.ts +182 -165
- package/src/index.ts +1 -1
- package/src/load.ts +210 -0
- package/src/preload.ts +198 -0
- package/src/runtime.ts +10 -7
- package/src/web-image.ts +118 -0
package/README.md
CHANGED
|
@@ -10,11 +10,11 @@ This is the core runtime package. The React layer is a separate package ([`@ring
|
|
|
10
10
|
bun add @ringozz/godot
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
The package ships no engine binary itself — it auto-selects a platform addon from its optional dependencies on install: `@ringozz/godot-macos-arm64`, `@ringozz/godot-windows-x86_64`, or `@ringozz/godot-web-wasm32` (the WASM build is installed everywhere and used on web). No `project.godot` is required — the engine boots from engine defaults with `res://` = your working directory.
|
|
13
|
+
The package ships no engine binary itself — it auto-selects a platform addon from its optional dependencies on install: `@ringozz/godot-macos-arm64`, `@ringozz/godot-windows-x86_64`, or `@ringozz/godot-web-wasm32` (the WASM build is installed everywhere and used on web). No `project.godot` is required — the engine boots from engine defaults with `res://` = your working directory (if a `project.godot` is present at cwd, it is loaded and `res://` still maps to cwd).
|
|
14
14
|
|
|
15
15
|
## Usage
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
On **desktop**, the engine is already running when you import — `getGodot()` returns the instance synchronously. Pump frames with `runGodot()`:
|
|
18
18
|
|
|
19
19
|
```ts
|
|
20
20
|
import { runGodot } from '@ringozz/godot';
|
|
@@ -31,6 +31,19 @@ root.addChild(label);
|
|
|
31
31
|
const done = runGodot(); // pumps frames until aborted
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
### Web
|
|
35
|
+
|
|
36
|
+
The wasm engine boots asynchronously, so on web the entry script must boot it **before** importing the app. `@ringozz/godot/runtime` reads the booted native module synchronously through the `@ringozz/godot/boot` export — the package's `browser` field remaps the desktop `boot.ts` leaf to `boot.browser.ts` in browser-targeted bundles, which hands the module over to `runtime.ts` via a shared module instance — keeping the shared module graph top-level-await free, which is also what makes Bun's HMR dev server work. On desktop `preloadGodot()` is a no-op (the engine boots at import).
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
// your web entry (referenced by <script type="module"> in the HTML)
|
|
40
|
+
import { preloadGodot } from '@ringozz/godot/boot';
|
|
41
|
+
await preloadGodot();
|
|
42
|
+
await import('./app.ts'); // may now import @ringozz/godot / react-godot freely
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
(That's exactly what `dev/demo.html.ts` does in this repo. A second `<script type="module">` tag can't replace the dynamic import — Bun's fullstack HTML bundler merges all script entries into one chunk, so the browser's in-order script/TLA serialization never applies.)
|
|
46
|
+
|
|
34
47
|
### What's exported where
|
|
35
48
|
|
|
36
49
|
| Specifier | Contents |
|
|
@@ -38,19 +51,67 @@ const done = runGodot(); // pumps frames until aborted
|
|
|
38
51
|
| `@ringozz/godot` | Value types (`Vector3`, `Color`, …), global enums, heap types, utility functions, global constants, `runGodot()`, RAF polyfills |
|
|
39
52
|
| `@ringozz/godot/ClassName` | Classes, one per module — e.g. `@ringozz/godot/Label`, `@ringozz/godot/Node` |
|
|
40
53
|
| `@ringozz/godot/runtime` | Low-level dispatch: `_C`, `_S`, `_get`, `_set`, `_R`, `_G`, `_P`, `getGodot`, `GodotVar` |
|
|
54
|
+
| `@ringozz/godot/boot` | Platform boot: `getNativeModule()`; web `preloadGodot()` (awaited before importing the app; no-op on desktop) |
|
|
55
|
+
| `@ringozz/godot/load` | Async resource loading: `loadResourceAsync` |
|
|
41
56
|
| `@ringozz/godot/debug` | `initDebug()`, `dumpStr`, `dumpTreeStr`, `statsStr`, `gc` |
|
|
42
57
|
|
|
43
58
|
Class-scoped enums use bare names (import `ProcessMode` from `@ringozz/godot/Node`, not `NodeProcessMode`).
|
|
44
59
|
|
|
60
|
+
### Async resource loading
|
|
61
|
+
|
|
62
|
+
The engine loads pre-baked native resources (`.scn`, `.ctex`, …) through `ResourceLoader`. On desktop these are real files (`res://` = cwd); on web there is no filesystem, so the asset's bundled files — `.import` sidecars as text and imported products (`.scn`/`.ctex`) as emitted asset paths — are staged into Emscripten's in-memory MEMFS (transient, no IndexedDB, no persistent copy) by the generated asset modules, handing the fetched bytes straight to MEMFS via Godot's own `copyToFS` (`FS.createDataFile` with `canOwn` — zero copy, JS-heap only). On web, `.ctex` files are decoded first: the embedded PNG/WebP blobs go through the browser's `createImageBitmap` and the container is rebuilt as raw RGBA (`DATA_FORMAT_IMAGE`), so no image codec runs in wasm. The source file itself is never fetched.
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { loadResourceAsync } from '@ringozz/godot/load';
|
|
66
|
+
import { PackedScene } from '@ringozz/godot/PackedScene';
|
|
67
|
+
import { Texture2D } from '@ringozz/godot/Texture2D';
|
|
68
|
+
|
|
69
|
+
const scene = await loadResourceAsync('res://models/foo.gltf', PackedScene); // PackedScene
|
|
70
|
+
const node = scene.instantiate();
|
|
71
|
+
const tex = await loadResourceAsync('res://textures/icon.jpg', Texture2D); // Texture2D
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- `loadResourceAsync(path, cls, files?)` — generic over the Godot class: `cls.name` is the `ResourceLoader` type hint, and the return type is `InstanceType<typeof cls>`. Loads in the background (`loadThreadedRequest` + status polling). Godot default arguments apply: the napi dispatch truncates trailing `undefined` args, so `loadThreadedRequest` uses its default `CACHE_MODE_REUSE` and the resource stays cached in the engine (scene re-references don't re-read the file). On web it only works for paths already staged into MEMFS (generated asset modules handle this). When the asset's `files` map is passed, the staged product files (`.ctex`/`.scn`/native sources) are deleted from MEMFS after the resource loads — only the `.import` sidecars are kept, since they route imported source paths to their products. The decoded texture bytes don't linger in the JS heap.
|
|
75
|
+
- `loadAsset(path, cls, files, deps, data)` — the generated asset modules' entry point (also usable directly): returns `{ materialize, load }`. It checks the engine's `ResourceCache` first (when the asset is already loaded, `load` resolves to the cached instance and staging is skipped), stages the bundled files via Godot's `copyToFS` (zero-copy handover of the fetched bytes; a no-op on desktop where the files already exist), loads via `loadResourceAsync`, and memoizes `load` on `data` (pass `import.meta.hot.data`) so HMR re-evaluations reuse the same fulfilled promise.
|
|
76
|
+
|
|
77
|
+
Asset imports are resolved by `@ringozz/godot/preload` (a Bun plugin, registered via `bunfig.toml` preload for `bun run`/`bun test` and via `[serve.static] plugins` for `Bun.serve`'s fullstack HTML routes; the module's default export is the single plugin object) into a module per asset:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import foo from './models/foo.gltf';
|
|
81
|
+
const scene = await foo; // PackedScene
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Each asset module's default export is its load **promise** (`const { materialize, load } = loadAsset(resPath, Class, files, deps, import.meta.hot.data)` — the default is `load`, memoized on the module's HMR data), started eagerly at module import, and it also exports a `materialize` promise (its own files plus its deps' files). It statically imports its own files Godot reads — `.import` sidecars and native text sources (`tscn`/`tres`/`po`) via the `text` loader, imported products (`.scn`/`.ctex`) via the `file` loader — and imports its **dependencies as generated modules too**, waiting only for their `materialize` before loading itself (dep loads start at module evaluation and run concurrently). Text-based source assets (`.gltf`/`.tscn`/`.tres`/`.obj`) are scanned for references (`res://` paths and relative paths ending in known asset extensions); referenced source assets become dep modules, raw `.mtl` files (OBJ materials) are recursed into, and raw `.bin` buffers are import-time only. The modules are virtual (namespace `godot`), and every import of the same asset resolves to the same one (evaluated once) — so the promise is a stable singleton, read with `await` or React 19's `use()`:
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
import foo from './models/foo.gltf';
|
|
88
|
+
import { Suspense, use } from 'react';
|
|
89
|
+
|
|
90
|
+
function Foo() {
|
|
91
|
+
const scene = use(foo); // suspends until the scene loads
|
|
92
|
+
return <FooView scene={scene} />;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
<Suspense fallback={<Label text="Loading…" />}>
|
|
96
|
+
<Foo />
|
|
97
|
+
</Suspense>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Each extension maps to its Godot class (source assets only — `gltf`/`tscn`/`obj` → `PackedScene`, `jpg`/`jpeg`/`png`/`webp` → `CompressedTexture2D`, `svg` → `Texture2D`, `exr`/`hdr` → `TextureLayered`, `wav` → `AudioStreamWAV`, `ogg` → `AudioStreamOggVorbis`, `mp3` → `AudioStreamMP3`, `tres` → `Resource`, `po` → `Translation`), declared in `src/assets.d.ts`. Imported products (`.scn`/`.ctex`) are never imported directly.
|
|
101
|
+
|
|
102
|
+
Textures are shared only through Godot's `ResourceLoader` path cache. The glTF import pipeline may produce per-scene texture instances (same `res://` path, separate objects), so don't assume two scenes referencing the same image share one texture object.
|
|
103
|
+
|
|
45
104
|
### Debugging
|
|
46
105
|
|
|
47
106
|
```ts
|
|
48
107
|
import { initDebug } from '@ringozz/godot/debug';
|
|
49
108
|
initDebug();
|
|
50
|
-
// singletons + helpers now on globalThis.$: $.Engine, $.dumpTreeStr(root), $.statsStr(), $.gc()
|
|
109
|
+
// singletons + helpers now on globalThis.$: $.Engine, $.dumpStr(node), $.dumpTreeStr(root), $.statsStr(), $.gc()
|
|
51
110
|
```
|
|
52
111
|
|
|
53
|
-
`
|
|
112
|
+
`dumpStr` renders value types inline (`(1.0, 2.0, 3.0)`, arrays, dictionaries) via Godot's `str()` and dumps objects via Godot's `var_to_str`, with the node's `name` prepended.
|
|
113
|
+
|
|
114
|
+
`initDebug` also registers an `uncaughtException` handler that keeps the process alive, polyfills `DOMRect` (not defined in Bun, needed by `getBoundingClientRect()`), and adds `getBoundingClientRect()` to `CanvasItem`/`Node3D`.
|
|
54
115
|
|
|
55
116
|
## Notes
|
|
56
117
|
|
package/package.json
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ringozz/godot",
|
|
3
3
|
"author": "Vladimir Davidovich",
|
|
4
|
-
"version": "4.7.1-
|
|
4
|
+
"version": "4.7.1-9",
|
|
5
5
|
"description": "Node-API bindings for Godot Engine — call Godot classes from JavaScript",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"godot"
|
|
8
|
+
],
|
|
6
9
|
"type": "module",
|
|
7
10
|
"main": "./src/index.ts",
|
|
8
11
|
"types": "./gen/index.ts",
|
|
9
12
|
"exports": {
|
|
10
13
|
".": "./src/index.ts",
|
|
11
14
|
"./runtime": "./src/runtime.ts",
|
|
15
|
+
"./boot": "./src/boot.ts",
|
|
16
|
+
"./load": "./src/load.ts",
|
|
12
17
|
"./debug": "./src/debug.ts",
|
|
18
|
+
"./preload": "./src/preload.ts",
|
|
13
19
|
"./*": "./gen/classes/*.ts"
|
|
14
20
|
},
|
|
21
|
+
"browser": {
|
|
22
|
+
"./src/boot.ts": "./src/boot.browser.ts"
|
|
23
|
+
},
|
|
15
24
|
"files": [
|
|
16
25
|
"src/",
|
|
17
26
|
"gen/",
|
|
@@ -27,11 +36,11 @@
|
|
|
27
36
|
"precision": "single"
|
|
28
37
|
},
|
|
29
38
|
"optionalDependencies": {
|
|
30
|
-
"@ringozz/godot-macos-arm64": "^4.7.1-
|
|
31
|
-
"@ringozz/godot-windows-x86_64": "^4.7.1-
|
|
32
|
-
"@ringozz/godot-web-wasm32": "^4.7.1-
|
|
39
|
+
"@ringozz/godot-macos-arm64": "^4.7.1-3",
|
|
40
|
+
"@ringozz/godot-windows-x86_64": "^4.7.1-3",
|
|
41
|
+
"@ringozz/godot-web-wasm32": "^4.7.1-9"
|
|
33
42
|
},
|
|
34
43
|
"peerDependencies": {
|
|
35
|
-
"@types/
|
|
44
|
+
"@types/bun": "*"
|
|
36
45
|
}
|
|
37
46
|
}
|
package/src/assets.d.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**********************************************************************
|
|
2
|
+
Copyright (c) Vladimir Davidovich. All rights reserved.
|
|
3
|
+
***********************************************************************/
|
|
4
|
+
|
|
5
|
+
// Ambient types for Godot asset imports, resolved by `@ringozz/godot/preload`
|
|
6
|
+
// into modules that export a `materialize` promise (files staged into MEMFS) and
|
|
7
|
+
// default to the load promise (`const { materialize, load } = loadAsset(path,
|
|
8
|
+
// Class, files, deps, import.meta.hot.data)` — `load` memoized on the module's
|
|
9
|
+
// HMR data, so re-evaluations return the same fulfilled promise). The default is
|
|
10
|
+
// a stable module singleton, so it can be passed to React's `use()` under
|
|
11
|
+
// `<Suspense>`.
|
|
12
|
+
|
|
13
|
+
declare module '*.tres' {
|
|
14
|
+
import type { Resource } from '@ringozz/godot/Resource';
|
|
15
|
+
const asset: Promise<Resource>;
|
|
16
|
+
export default asset;
|
|
17
|
+
export const materialize: Promise<unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
declare module '*.po' {
|
|
21
|
+
import type { Translation } from '@ringozz/godot/Translation';
|
|
22
|
+
const asset: Promise<Translation>;
|
|
23
|
+
export default asset;
|
|
24
|
+
export const materialize: Promise<unknown>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
declare module '*.gltf' {
|
|
28
|
+
import type { PackedScene } from '@ringozz/godot/PackedScene';
|
|
29
|
+
const asset: Promise<PackedScene>;
|
|
30
|
+
export default asset;
|
|
31
|
+
export const materialize: Promise<unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
declare module '*.tscn' {
|
|
35
|
+
import type { PackedScene } from '@ringozz/godot/PackedScene';
|
|
36
|
+
const asset: Promise<PackedScene>;
|
|
37
|
+
export default asset;
|
|
38
|
+
export const materialize: Promise<unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
declare module '*.obj' {
|
|
42
|
+
import type { PackedScene } from '@ringozz/godot/PackedScene';
|
|
43
|
+
const asset: Promise<PackedScene>;
|
|
44
|
+
export default asset;
|
|
45
|
+
export const materialize: Promise<unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
declare module '*.jpg' {
|
|
49
|
+
import type { CompressedTexture2D } from '@ringozz/godot/CompressedTexture2D';
|
|
50
|
+
const asset: Promise<CompressedTexture2D>;
|
|
51
|
+
export default asset;
|
|
52
|
+
export const materialize: Promise<unknown>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
declare module '*.jpeg' {
|
|
56
|
+
import type { CompressedTexture2D } from '@ringozz/godot/CompressedTexture2D';
|
|
57
|
+
const asset: Promise<CompressedTexture2D>;
|
|
58
|
+
export default asset;
|
|
59
|
+
export const materialize: Promise<unknown>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
declare module '*.png' {
|
|
63
|
+
import type { CompressedTexture2D } from '@ringozz/godot/CompressedTexture2D';
|
|
64
|
+
const asset: Promise<CompressedTexture2D>;
|
|
65
|
+
export default asset;
|
|
66
|
+
export const materialize: Promise<unknown>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
declare module '*.webp' {
|
|
70
|
+
import type { CompressedTexture2D } from '@ringozz/godot/CompressedTexture2D';
|
|
71
|
+
const asset: Promise<CompressedTexture2D>;
|
|
72
|
+
export default asset;
|
|
73
|
+
export const materialize: Promise<unknown>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
declare module '*.svg' {
|
|
77
|
+
import type { Texture2D } from '@ringozz/godot/Texture2D';
|
|
78
|
+
const asset: Promise<Texture2D>;
|
|
79
|
+
export default asset;
|
|
80
|
+
export const materialize: Promise<unknown>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
declare module '*.exr' {
|
|
84
|
+
import type { TextureLayered } from '@ringozz/godot/TextureLayered';
|
|
85
|
+
const asset: Promise<TextureLayered>;
|
|
86
|
+
export default asset;
|
|
87
|
+
export const materialize: Promise<unknown>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
declare module '*.hdr' {
|
|
91
|
+
import type { TextureLayered } from '@ringozz/godot/TextureLayered';
|
|
92
|
+
const asset: Promise<TextureLayered>;
|
|
93
|
+
export default asset;
|
|
94
|
+
export const materialize: Promise<unknown>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
declare module '*.wav' {
|
|
98
|
+
import type { AudioStreamWAV } from '@ringozz/godot/AudioStreamWAV';
|
|
99
|
+
const asset: Promise<AudioStreamWAV>;
|
|
100
|
+
export default asset;
|
|
101
|
+
export const materialize: Promise<unknown>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
declare module '*.ogg' {
|
|
105
|
+
import type { AudioStreamOggVorbis } from '@ringozz/godot/AudioStreamOggVorbis';
|
|
106
|
+
const asset: Promise<AudioStreamOggVorbis>;
|
|
107
|
+
export default asset;
|
|
108
|
+
export const materialize: Promise<unknown>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
declare module '*.mp3' {
|
|
112
|
+
import type { AudioStreamMP3 } from '@ringozz/godot/AudioStreamMP3';
|
|
113
|
+
const asset: Promise<AudioStreamMP3>;
|
|
114
|
+
export default asset;
|
|
115
|
+
export const materialize: Promise<unknown>;
|
|
116
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**********************************************************************
|
|
2
|
+
Copyright (c) Vladimir Davidovich. All rights reserved.
|
|
3
|
+
***********************************************************************/
|
|
4
|
+
|
|
5
|
+
// Web boot leaf (the `browser`-field replacement for `boot.ts`): boots the
|
|
6
|
+
// Godot wasm engine and hands the module to `runtime.ts` as the native module
|
|
7
|
+
// *namespace* (whose `.default` is the wasm module). This module is a leaf — it
|
|
8
|
+
// imports nothing from the godot class graph, so a web entry can call
|
|
9
|
+
// `preloadGodot()` *before* importing the app without pulling in `runtime.ts`.
|
|
10
|
+
// `runtime.ts` statically imports this module too (through the browser-field
|
|
11
|
+
// remap), so both sides share the same module instance and `nativeModule`
|
|
12
|
+
// below is the handoff — no global needed.
|
|
13
|
+
|
|
14
|
+
let nativeModule: unknown = null;
|
|
15
|
+
|
|
16
|
+
/** The cached wasm module import (its namespace), or `null` until `preloadGodot()` completes. */
|
|
17
|
+
export function getNativeModule(): unknown {
|
|
18
|
+
return nativeModule;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Boots the Godot wasm engine so `@ringozz/godot/runtime` can read the native
|
|
23
|
+
* module synchronously via {@link getNativeModule} — keeping the shared module
|
|
24
|
+
* graph top-level-await free (which is what lets Bun's HMR module loader
|
|
25
|
+
* evaluate sibling importers in order). Returns the wasm module's namespace
|
|
26
|
+
* (the native module is its `.default`, which `runtime.ts` destructures).
|
|
27
|
+
* Idempotent: subsequent calls return the cached import without re-booting.
|
|
28
|
+
*
|
|
29
|
+
* A web entry must `await preloadGodot()` before importing the app:
|
|
30
|
+
*
|
|
31
|
+
* ```ts
|
|
32
|
+
* import { preloadGodot } from '@ringozz/godot/boot';
|
|
33
|
+
* await preloadGodot();
|
|
34
|
+
* await import('./app.ts');
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export async function preloadGodot(): Promise<unknown> {
|
|
38
|
+
return nativeModule ??= await import('@ringozz/godot-web-wasm32');
|
|
39
|
+
}
|
package/src/boot.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**********************************************************************
|
|
2
|
+
Copyright (c) Vladimir Davidovich. All rights reserved.
|
|
3
|
+
***********************************************************************/
|
|
4
|
+
|
|
5
|
+
// Desktop boot leaf: resolves the native addon for the current host and
|
|
6
|
+
// hands it to `runtime.ts` as the native module *namespace* (whose `.default`
|
|
7
|
+
// is the addon's `module.exports`). `runtime.ts` imports this module, and the
|
|
8
|
+
// package's `browser` field remaps it to `boot.browser.ts` when bundling for
|
|
9
|
+
// `target: 'browser'` — so the Bun-only `import.meta.require` below never
|
|
10
|
+
// reaches a web bundle.
|
|
11
|
+
|
|
12
|
+
const { platform, arch } = globalThis.process ?? {};
|
|
13
|
+
const mapping: any = {
|
|
14
|
+
'darwin-arm64': 'macos-arm64',
|
|
15
|
+
'win32-x64': 'windows-x86_64',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** The native addon's module namespace (sync dlopen; engine boots on first call). */
|
|
19
|
+
export function getNativeModule(): unknown {
|
|
20
|
+
return import.meta.require(`@ringozz/godot-${mapping[`${platform}-${arch}`]}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Desktop boots synchronously at import — nothing to preload. */
|
|
24
|
+
export async function preloadGodot(): Promise<unknown> {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
package/src/debug.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Copyright (c) Vladimir Davidovich. All rights reserved.
|
|
3
3
|
***********************************************************************/
|
|
4
4
|
|
|
5
|
-
import { Rect2, Vector3 } from './index.ts';
|
|
5
|
+
import { Rect2, str, varToStr, Vector3 } from './index.ts';
|
|
6
6
|
import { CanvasItem } from '../gen/classes/CanvasItem.ts';
|
|
7
7
|
import { ClassDB } from '../gen/classes/ClassDB.ts';
|
|
8
8
|
import { Control } from '../gen/classes/Control.ts';
|
|
@@ -14,40 +14,25 @@ import { OS } from '../gen/classes/OS.ts';
|
|
|
14
14
|
import { Time } from '../gen/classes/Time.ts';
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* Dump a Godot object
|
|
18
|
-
*
|
|
17
|
+
* Dump a Godot value or object as a string.
|
|
18
|
+
* Value types (vectors, arrays, dictionaries, …) render via Godot's `str()`.
|
|
19
|
+
* Objects dump via Godot's `var_to_str` — its own property walk + recursion —
|
|
20
|
+
* with the node's `name` prepended when present.
|
|
19
21
|
*
|
|
20
|
-
* @param obj - Any Godot wrapper (Node, Resource,
|
|
21
|
-
* @param depth - Max recursion depth into sub-objects (default 2)
|
|
22
|
-
* @param maxProps - Max properties to show per object (default 30)
|
|
22
|
+
* @param obj - Any Godot wrapper (Node, Resource, …), value type, or plain value
|
|
23
23
|
*/
|
|
24
|
-
export function dumpStr(obj: unknown
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
let count = 0;
|
|
31
|
-
try {
|
|
32
|
-
const plist = (obj as any).getPropertyList?.();
|
|
33
|
-
if (plist?.size) {
|
|
34
|
-
for (let i = 0; i < plist.size() && count < maxProps; i++) {
|
|
35
|
-
const name = plist.get(i).get('name');
|
|
36
|
-
if (!name || (name + '').startsWith('_')) continue;
|
|
37
|
-
let val: unknown;
|
|
38
|
-
try { val = (obj as any).get(name); } catch { val = '<err>'; }
|
|
39
|
-
if (typeof val === 'object' && val !== null && depth > 1)
|
|
40
|
-
lines.push(` ${name}:\n${indent(dumpStr(val, depth - 1, maxProps), ' ')}`);
|
|
41
|
-
else
|
|
42
|
-
lines.push(` ${name}: ${String(val)}`);
|
|
43
|
-
count++;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
} catch { /* not a GodotVar */ }
|
|
47
|
-
return lines.join('\n');
|
|
48
|
-
}
|
|
24
|
+
export function dumpStr(obj: unknown): string {
|
|
25
|
+
if (obj == null || typeof obj !== 'object') return String(obj);
|
|
26
|
+
|
|
27
|
+
// Value types & heap containers (Vector3, Array, Dictionary, …) have no getClass() —
|
|
28
|
+
// let Godot's str() render them.
|
|
29
|
+
if (!(obj as any).getClass) return str(obj as any);
|
|
49
30
|
|
|
50
|
-
|
|
31
|
+
const dump = varToStr(obj as any);
|
|
32
|
+
let name: unknown;
|
|
33
|
+
try { name = (obj as any).name; } catch { name = undefined; }
|
|
34
|
+
return name === undefined || name === null || name === '' ? dump : `${name}: ${dump}`;
|
|
35
|
+
}
|
|
51
36
|
|
|
52
37
|
/**
|
|
53
38
|
* Serialize a scene subtree as a tree-formatted string.
|
|
@@ -56,34 +41,36 @@ function indent(s: string, prefix: string): string { return s.split('\n').join('
|
|
|
56
41
|
* @param depth - Max depth (default 99)
|
|
57
42
|
*/
|
|
58
43
|
export function dumpTreeStr(node: Node, depth = 99): string {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
44
|
+
const lines: string[] = [];
|
|
45
|
+
(function walk(n: Node, d: number, ind: string) {
|
|
46
|
+
if (d <= 0) return;
|
|
47
|
+
lines.push(`${ind}${n.getClass()} "${n.name}" (${n.getChildCount()} children)`);
|
|
48
|
+
for (let i = 0; i < n.getChildCount(); i++) {
|
|
49
|
+
const c = n.getChild(i);
|
|
50
|
+
if (c) walk(c as Node, d - 1, ind + ' ');
|
|
51
|
+
}
|
|
52
|
+
})(node, depth, '');
|
|
53
|
+
return lines.join('\n');
|
|
69
54
|
}
|
|
70
55
|
|
|
71
56
|
/**
|
|
72
57
|
* Return a snapshot of engine runtime stats as a formatted string.
|
|
73
58
|
*
|
|
74
59
|
* Includes process frames, FPS, physics frames, node count, time scale,
|
|
75
|
-
* and main-loop class.
|
|
60
|
+
* and main-loop class. Never throws: `Engine.getMainLoop()` can be null or
|
|
61
|
+
* throw while the engine is starting/stopping, in which case those rows show `N/A`.
|
|
76
62
|
*/
|
|
77
63
|
export function statsStr(): string {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
64
|
+
let tree: any = null;
|
|
65
|
+
try { tree = Engine.getMainLoop(); } catch { tree = null; }
|
|
66
|
+
return [
|
|
67
|
+
`Frames: ${Engine.getProcessFrames()}`,
|
|
68
|
+
`FPS: ${Engine.getFramesPerSecond()}`,
|
|
69
|
+
`Physics: ${Engine.getPhysicsFrames()}`,
|
|
70
|
+
`Nodes: ${tree?.getNodeCount?.() ?? 'N/A'}`,
|
|
71
|
+
`Time scale: ${Engine.timeScale}`,
|
|
72
|
+
`Main loop: ${tree ? tree.getClass() : 'N/A'}`,
|
|
73
|
+
].join('\n');
|
|
87
74
|
}
|
|
88
75
|
|
|
89
76
|
/**
|
|
@@ -91,16 +78,43 @@ export function statsStr(): string {
|
|
|
91
78
|
* Requires the `--expose-gc` Node.js flag.
|
|
92
79
|
*/
|
|
93
80
|
export function gc(): string {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
81
|
+
const before = globalThis.process?.memoryUsage?.().heapUsed;
|
|
82
|
+
if (globalThis.Bun)
|
|
83
|
+
globalThis.Bun.gc(true);
|
|
84
|
+
else if (globalThis.gc)
|
|
85
|
+
globalThis.gc({ type: 'major', execution: 'sync' });
|
|
86
|
+
else
|
|
87
|
+
return 'gc() unavailable — need --expose-gc';
|
|
88
|
+
|
|
89
|
+
const after = globalThis.process?.memoryUsage?.().heapUsed;
|
|
90
|
+
const freed = typeof before === 'number' && typeof after === 'number' ? (before - after) / 1024 : 0;
|
|
91
|
+
return `GC: freed ${freed.toFixed(0)} KB`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* `DOMRect` is not a global in Bun (desktop), but `getBoundingClientRect()`
|
|
96
|
+
* constructs one. Install a minimal spec-compatible polyfill on desktop so the
|
|
97
|
+
* helper works identically on desktop and web (browsers already define it).
|
|
98
|
+
*/
|
|
99
|
+
function ensureDOMRect(): void {
|
|
100
|
+
if (typeof (globalThis as any).DOMRect !== 'undefined') return;
|
|
101
|
+
(globalThis as any).DOMRect = class DOMRect {
|
|
102
|
+
x: number;
|
|
103
|
+
y: number;
|
|
104
|
+
width: number;
|
|
105
|
+
height: number;
|
|
106
|
+
constructor(x = 0, y = 0, width = 0, height = 0) {
|
|
107
|
+
this.x = x;
|
|
108
|
+
this.y = y;
|
|
109
|
+
this.width = width;
|
|
110
|
+
this.height = height;
|
|
111
|
+
}
|
|
112
|
+
get left() { return this.x; }
|
|
113
|
+
get top() { return this.y; }
|
|
114
|
+
get right() { return this.x + this.width; }
|
|
115
|
+
get bottom() { return this.y + this.height; }
|
|
116
|
+
toJSON() { return { x: this.x, y: this.y, width: this.width, height: this.height }; }
|
|
117
|
+
};
|
|
104
118
|
}
|
|
105
119
|
|
|
106
120
|
/**
|
|
@@ -111,109 +125,112 @@ export function gc(): string {
|
|
|
111
125
|
* breakpoints in any module.
|
|
112
126
|
* - Registers an `uncaughtException` handler that logs but keeps the process
|
|
113
127
|
* alive for interactive debugging.
|
|
128
|
+
* - Polyfills `DOMRect` (needed by `getBoundingClientRect()` on desktop).
|
|
114
129
|
*/
|
|
115
130
|
export function initDebug(): void {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
131
|
+
globalThis.process?.on?.('uncaughtException', (err, origin) => {
|
|
132
|
+
console.error(`\n✗ ${origin}:`, err.stack);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
ensureDOMRect();
|
|
136
|
+
|
|
137
|
+
Object.defineProperty(CanvasItem.prototype, 'getBoundingClientRect', {
|
|
138
|
+
configurable: true,
|
|
139
|
+
value(this: CanvasItem): DOMRect | null {
|
|
140
|
+
const scale = DisplayServer.screenGetScale();
|
|
141
|
+
if (this instanceof Control) {
|
|
142
|
+
const rc = (this as Control).getGlobalRect();
|
|
143
|
+
return new DOMRect(
|
|
144
|
+
rc.position.x / scale, rc.position.y / scale,
|
|
145
|
+
rc.size.x / scale, rc.size.y / scale,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (typeof (this as any).getRect !== 'function') return null;
|
|
150
|
+
|
|
151
|
+
const rect = (this as any).getRect() as Rect2;
|
|
152
|
+
const t = this.getScreenTransform();
|
|
153
|
+
|
|
154
|
+
const pos = rect.position;
|
|
155
|
+
const sz = rect.size;
|
|
156
|
+
const tx = t.x;
|
|
157
|
+
const ty = t.y;
|
|
158
|
+
const to = t.origin;
|
|
159
|
+
|
|
160
|
+
const x0 = pos.x, y0 = pos.y;
|
|
161
|
+
const x1 = x0 + sz.x, y1 = y0 + sz.y;
|
|
162
|
+
const txx = tx.x, txy = tx.y;
|
|
163
|
+
const tyx = ty.x, tyy = ty.y;
|
|
164
|
+
const tox = to.x, toy = to.y;
|
|
165
|
+
|
|
166
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
167
|
+
const accum = (px: number, py: number) => {
|
|
168
|
+
if (px < minX) minX = px; if (py < minY) minY = py;
|
|
169
|
+
if (px > maxX) maxX = px; if (py > maxY) maxY = py;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
accum(txx * x0 + tyx * y0 + tox, txy * x0 + tyy * y0 + toy);
|
|
173
|
+
accum(txx * x1 + tyx * y0 + tox, txy * x1 + tyy * y0 + toy);
|
|
174
|
+
accum(txx * x0 + tyx * y1 + tox, txy * x0 + tyy * y1 + toy);
|
|
175
|
+
accum(txx * x1 + tyx * y1 + tox, txy * x1 + tyy * y1 + toy);
|
|
176
|
+
|
|
177
|
+
return new DOMRect(
|
|
178
|
+
minX / scale, minY / scale,
|
|
179
|
+
(maxX - minX) / scale, (maxY - minY) / scale,
|
|
180
|
+
);
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
Object.defineProperty(Node3D.prototype, 'getBoundingClientRect', {
|
|
185
|
+
configurable: true,
|
|
186
|
+
value(this: Node3D): DOMRect | null {
|
|
187
|
+
if (typeof (this as any).getAabb !== 'function') return null;
|
|
188
|
+
|
|
189
|
+
const aabb = (this as any).getAabb();
|
|
190
|
+
const gt = this.globalTransform;
|
|
191
|
+
const viewport = this.getViewport();
|
|
192
|
+
if (!viewport) return null;
|
|
193
|
+
const camera = viewport.getCamera3d();
|
|
194
|
+
if (!camera) return null;
|
|
195
|
+
const scale = DisplayServer.screenGetScale();
|
|
196
|
+
|
|
197
|
+
const b = gt.basis;
|
|
198
|
+
const bx = b.x, by = b.y, bz = b.z;
|
|
199
|
+
const o = gt.origin;
|
|
200
|
+
const p = aabb.position, e = aabb.end;
|
|
201
|
+
|
|
202
|
+
const [px, py, pz] = [p.x, p.y, p.z];
|
|
203
|
+
const [ex, ey, ez] = [e.x, e.y, e.z];
|
|
204
|
+
const [bxx, bxy, bxz] = [bx.x, bx.y, bx.z];
|
|
205
|
+
const [byx, byy, byz] = [by.x, by.y, by.z];
|
|
206
|
+
const [bzx, bzy, bzz] = [bz.x, bz.y, bz.z];
|
|
207
|
+
const [ox, oy, oz] = [o.x, o.y, o.z];
|
|
208
|
+
|
|
209
|
+
const corners = [
|
|
210
|
+
[px, py, pz], [ex, py, pz], [px, ey, pz], [ex, ey, pz],
|
|
211
|
+
[px, py, ez], [ex, py, ez], [px, ey, ez], [ex, ey, ez],
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
215
|
+
|
|
216
|
+
for (const [cx, cy, cz] of corners) {
|
|
217
|
+
const wx = bxx * cx + byx * cy + bzx * cz + ox;
|
|
218
|
+
const wy = bxy * cx + byy * cy + bzy * cz + oy;
|
|
219
|
+
const wz = bxz * cx + byz * cy + bzz * cz + oz;
|
|
220
|
+
const s = camera.unprojectPosition(new Vector3(wx, wy, wz));
|
|
221
|
+
if (s.x < minX) minX = s.x; if (s.y < minY) minY = s.y;
|
|
222
|
+
if (s.x > maxX) maxX = s.x; if (s.y > maxY) maxY = s.y;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return new DOMRect(
|
|
226
|
+
minX / scale, minY / scale,
|
|
227
|
+
(maxX - minX) / scale, (maxY - minY) / scale,
|
|
228
|
+
);
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
(globalThis as any).$ = {
|
|
233
|
+
Engine, OS, ClassDB, Time,
|
|
234
|
+
dumpStr, dumpTreeStr, statsStr, gc,
|
|
235
|
+
};
|
|
219
236
|
}
|
package/src/index.ts
CHANGED
|
@@ -26,7 +26,7 @@ Object.assign(globalThis as any, { requestAnimationFrame, cancelAnimationFrame }
|
|
|
26
26
|
export async function runGodot(signal?: AbortSignal, unmount?: () => PromiseLike<void>) {
|
|
27
27
|
signal?.throwIfAborted();
|
|
28
28
|
signal?.addEventListener('abort', () => {
|
|
29
|
-
(Engine.getMainLoop() as SceneTree)
|
|
29
|
+
(Engine.getMainLoop() as SceneTree)?.quit();
|
|
30
30
|
}, { once: true });
|
|
31
31
|
|
|
32
32
|
const godot = getGodot();
|
package/src/load.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**********************************************************************
|
|
2
|
+
Copyright (c) Vladimir Davidovich. All rights reserved.
|
|
3
|
+
***********************************************************************/
|
|
4
|
+
|
|
5
|
+
import { DirAccess } from '../gen/classes/DirAccess.ts';
|
|
6
|
+
import { FileAccess } from '../gen/classes/FileAccess.ts';
|
|
7
|
+
import { ProjectSettings } from '../gen/classes/ProjectSettings.ts';
|
|
8
|
+
import { ResourceLoader, ThreadLoadStatus } from '../gen/classes/ResourceLoader.ts';
|
|
9
|
+
import { ResourceUID } from '../gen/classes/ResourceUID.ts';
|
|
10
|
+
import type { Resource } from '../gen/classes/Resource.ts';
|
|
11
|
+
import { stageFile } from './runtime.ts';
|
|
12
|
+
import { decodeCtex } from './web-image.ts';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A Godot `Resource` subclass constructor: its `.name` is the registered class
|
|
16
|
+
* name (used as the `ResourceLoader` type hint), and `InstanceType<C>` is the
|
|
17
|
+
* loaded resource type.
|
|
18
|
+
*/
|
|
19
|
+
type ResourceConstructor = { new(...args: any[]): Resource } & Function;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A file to stage before loading, keyed by `res://` path: sidecars (`.import`)
|
|
23
|
+
* arrive as `content` (text); imported products (`.scn`/`.ctex`) arrive as
|
|
24
|
+
* `path` (fetched).
|
|
25
|
+
*/
|
|
26
|
+
interface AssetFile {
|
|
27
|
+
content?: string;
|
|
28
|
+
path?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type AssetFiles = Record<string, AssetFile>;
|
|
32
|
+
|
|
33
|
+
const UID_RE = /uid="(uid:\/\/[\w.]+)"/;
|
|
34
|
+
|
|
35
|
+
// Godot encodes `uid://` numbers in base 34 over `a-y` then `0-8`
|
|
36
|
+
// (core/io/resource_uid.cpp). Decode in JS with BigInt: `ResourceUID.textToId`
|
|
37
|
+
// returns a JS number, and these ids exceed `Number.MAX_SAFE_INTEGER`, so the
|
|
38
|
+
// low digits would be lost. BigInt flows through `_C` losslessly (int64).
|
|
39
|
+
const UID_CHARS = 'abcdefghijklmnopqrstuvwxy012345678';
|
|
40
|
+
|
|
41
|
+
function uidToId(uid: string): bigint {
|
|
42
|
+
let id = 0n;
|
|
43
|
+
for (let i = 6; i < uid.length; i++) {
|
|
44
|
+
id = id * 34n + BigInt(UID_CHARS.indexOf(uid[i]));
|
|
45
|
+
}
|
|
46
|
+
return id;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Registers an asset's `uid://` → `res://` path so scene ext_resource references
|
|
51
|
+
* resolve it (Godot's `ResourceUID` map) instead of warning and falling back to
|
|
52
|
+
* the stored text path. Idempotent; skips unknown/malformed uids.
|
|
53
|
+
*/
|
|
54
|
+
function registerAssetUid(uid: string, resPath: string): void {
|
|
55
|
+
const id = uidToId(uid);
|
|
56
|
+
if (id !== 0n && !ResourceUID.hasId(id as unknown as number)) {
|
|
57
|
+
ResourceUID.addId(id as unknown as number, resPath);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function materialize([resPath, entry]: [string, AssetFile]): Promise<void> {
|
|
62
|
+
if (FileAccess.fileExists(resPath)) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
let bytes: Uint8Array;
|
|
66
|
+
if (entry.content !== undefined) {
|
|
67
|
+
// Sidecars (`.import`) and native text sources carry the asset's `uid=`;
|
|
68
|
+
// register it so scenes resolve by uid. `.import` maps to the source path
|
|
69
|
+
// (resPath minus the suffix); native files map to themselves.
|
|
70
|
+
const uid = entry.content.match(UID_RE)?.[1];
|
|
71
|
+
if (uid) {
|
|
72
|
+
registerAssetUid(uid, resPath.endsWith('.import') ? resPath.slice(0, -'.import'.length) : resPath);
|
|
73
|
+
}
|
|
74
|
+
bytes = new TextEncoder().encode(entry.content);
|
|
75
|
+
} else if (entry.path) {
|
|
76
|
+
const res = await fetch(entry.path);
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
bytes = new Uint8Array(await res.arrayBuffer());
|
|
81
|
+
if (resPath.endsWith('.ctex')) {
|
|
82
|
+
// On web, decode embedded PNG/WebP blobs with the browser before
|
|
83
|
+
// staging, so the engine never runs an image codec (see web-image.ts).
|
|
84
|
+
bytes = await decodeCtex(bytes);
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
stageFile?.(ProjectSettings.globalizePath(resPath), bytes);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const nextTick = () => new Promise(requestAnimationFrame);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* An asset module as generated by `@ringozz/godot/preload`: `default` is the
|
|
96
|
+
* load promise, `materialize` resolves once its bundled files (plus its deps'
|
|
97
|
+
* files) are staged on the engine's filesystem.
|
|
98
|
+
*/
|
|
99
|
+
interface AssetModule {
|
|
100
|
+
default: Promise<unknown>;
|
|
101
|
+
materialize: Promise<unknown>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Stages the asset's bundled files before loading: `.import` sidecars as text
|
|
106
|
+
* and imported products (`.scn`/`.ctex`) as fetched paths. On web the bytes are
|
|
107
|
+
* written straight into Emscripten's MEMFS via Godot's `copyToFS` (JS-heap only,
|
|
108
|
+
* no wasm copy); a no-op on desktop where the files already exist. `deps`
|
|
109
|
+
* supplies referenced asset modules whose file-staging is awaited (so Godot can
|
|
110
|
+
* resolve them) and whose load failures are logged.
|
|
111
|
+
*/
|
|
112
|
+
function materializeFiles(files: AssetFiles, deps: AssetModule[] = []): Promise<unknown> {
|
|
113
|
+
for (const dep of deps) {
|
|
114
|
+
dep.default.catch((err) => console.error('[godot] dependency load failed:', err));
|
|
115
|
+
}
|
|
116
|
+
if (stageFile === undefined) {
|
|
117
|
+
// Desktop: the files already exist on disk (res:// = cwd) and uids come
|
|
118
|
+
// from `.godot/uid_cache.bin`, so there is nothing to stage.
|
|
119
|
+
return Promise.resolve();
|
|
120
|
+
}
|
|
121
|
+
return Promise.all([...Object.entries(files).map(materialize), ...deps.map((dep) => dep.materialize)]);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Returns a promise resolving to the resource already cached in the engine at
|
|
126
|
+
* `path` (`ResourceCache`), or `null` when it isn't loaded yet. Used by
|
|
127
|
+
* {@link loadAsset} to skip file staging and the threaded load when a
|
|
128
|
+
* re-evaluated module (e.g. web HMR) references an asset that is still cached.
|
|
129
|
+
* `getCachedRef` returns the same JS wrapper as `loadThreadedGet` (instance
|
|
130
|
+
* binding), so identity is preserved.
|
|
131
|
+
*/
|
|
132
|
+
function cachedResource<C extends ResourceConstructor>(path: string): Promise<InstanceType<C>> | null {
|
|
133
|
+
const result = ResourceLoader.getCachedRef(path) as InstanceType<C>;
|
|
134
|
+
return result ? Promise.resolve(result) : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Entry point for the generated asset modules (`@ringozz/godot/preload`): checks
|
|
139
|
+
* the engine's `ResourceCache` once (`cachedResource`), stages the bundled files
|
|
140
|
+
* via `materializeFiles` when needed, then loads via {@link loadResourceAsync},
|
|
141
|
+
* memoizing the resulting load promise on `data` (the module's
|
|
142
|
+
* `import.meta.hot.data`, carried across HMR re-evaluations; `{}` on desktop
|
|
143
|
+
* where modules evaluate once). Memoization keeps `use()` seeing the **same**
|
|
144
|
+
* fulfilled promise object across re-evaluations — no Suspense fallback flash on
|
|
145
|
+
* hot reload; a rejected load is evicted so the next evaluation retries. Returns
|
|
146
|
+
* the `materialize` promise (own + deps' files staged) and the load promise.
|
|
147
|
+
*/
|
|
148
|
+
export function loadAsset<C extends ResourceConstructor>(
|
|
149
|
+
path: string,
|
|
150
|
+
cls: C,
|
|
151
|
+
files: AssetFiles,
|
|
152
|
+
deps: AssetModule[] = [],
|
|
153
|
+
data: Record<string, unknown> = {},
|
|
154
|
+
): { materialize: Promise<unknown>; load: Promise<InstanceType<C>> } {
|
|
155
|
+
const cached = cachedResource<C>(path);
|
|
156
|
+
const materialize = cached ? Promise.resolve() : materializeFiles(files, deps);
|
|
157
|
+
const existing = data[path] as Promise<InstanceType<C>> | undefined;
|
|
158
|
+
const load = existing ?? cached ?? materialize.then(() => loadResourceAsync(path, cls, files));
|
|
159
|
+
data[path] = load;
|
|
160
|
+
load.catch(() => {
|
|
161
|
+
if (data[path] === load) delete data[path];
|
|
162
|
+
});
|
|
163
|
+
return { materialize, load };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Loads a resource in the background. `cls` supplies both the `ResourceLoader`
|
|
168
|
+
* type hint (its registered `.name`) and the return type. On web, call
|
|
169
|
+
* {@link materializeFiles} with the asset's bundled files first; on desktop the
|
|
170
|
+
* files already exist. Pass the same `files` map to have the staged files
|
|
171
|
+
* deleted from MEMFS once the resource is loaded (the resource stays cached in
|
|
172
|
+
* the engine, so the bytes are no longer needed).
|
|
173
|
+
*/
|
|
174
|
+
export async function loadResourceAsync<C extends ResourceConstructor>(
|
|
175
|
+
path: string,
|
|
176
|
+
cls: C,
|
|
177
|
+
files?: AssetFiles,
|
|
178
|
+
): Promise<InstanceType<C>> {
|
|
179
|
+
const err = ResourceLoader.loadThreadedRequest(path, cls.name);
|
|
180
|
+
if (err) {
|
|
181
|
+
throw new Error(`loadResourceAsync(${path}): loadThreadedRequest failed (${err})`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let result: InstanceType<C>;
|
|
185
|
+
while (true) {
|
|
186
|
+
const status = ResourceLoader.loadThreadedGetStatus(path);
|
|
187
|
+
if (status === ThreadLoadStatus.THREAD_LOAD_LOADED) {
|
|
188
|
+
result = ResourceLoader.loadThreadedGet(path) as InstanceType<C>;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
if (status === ThreadLoadStatus.THREAD_LOAD_FAILED || status === ThreadLoadStatus.THREAD_LOAD_INVALID_RESOURCE) {
|
|
192
|
+
throw new Error(`loadResourceAsync(${path}): load failed (status ${status})`);
|
|
193
|
+
}
|
|
194
|
+
await nextTick();
|
|
195
|
+
}
|
|
196
|
+
if (files && stageFile !== undefined) {
|
|
197
|
+
// Keep `.import` sidecars — they route imported source paths to their
|
|
198
|
+
// products (ResourceFormatImporter recognizes a path by its sidecar).
|
|
199
|
+
// Delete everything else: the products are read only on a cache miss, and
|
|
200
|
+
// the resource stays cached after loading, so the bytes are dead weight.
|
|
201
|
+
// Best-effort: a file may already be gone (another module's cleanup or a
|
|
202
|
+
// cache-miss re-stage).
|
|
203
|
+
for (const resPath of Object.keys(files)) {
|
|
204
|
+
if (!resPath.endsWith('.import')) {
|
|
205
|
+
DirAccess.removeAbsolute(resPath);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return result;
|
|
210
|
+
}
|
package/src/preload.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**********************************************************************
|
|
2
|
+
Copyright (c) Vladimir Davidovich. All rights reserved.
|
|
3
|
+
***********************************************************************/
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, resolve } from 'node:path';
|
|
7
|
+
import { plugin } from 'bun';
|
|
8
|
+
import type { BunPlugin } from 'bun';
|
|
9
|
+
|
|
10
|
+
// Registers the godot-assets plugin: imports of Godot source assets become
|
|
11
|
+
// virtual modules (namespace `godot`) that call `loadAsset` (`@ringozz/godot/load`)
|
|
12
|
+
// and export the resulting `materialize` (own + deps' files) and load promise.
|
|
13
|
+
// `loadAsset` checks the engine's `ResourceCache` once; when the asset is
|
|
14
|
+
// already loaded (e.g. a re-evaluated module under web HMR) the load resolves to
|
|
15
|
+
// the cached instance and `materialize` short-circuits, skipping file staging
|
|
16
|
+
// and the threaded load. The load promise persists on the module's
|
|
17
|
+
// `import.meta.hot.data` (per-module state carried across hot replacements), so
|
|
18
|
+
// HMR re-evaluations return the *same* fulfilled promise object — React's `use()`
|
|
19
|
+
// keeps its tracked status and never re-suspends (no Suspense fallback flash); a
|
|
20
|
+
// rejected load is evicted from `data` so the next evaluation retries; on
|
|
21
|
+
// desktop `data` is `{}` and modules evaluate once. Dependencies are generated
|
|
22
|
+
// modules too, so a dep's load starts at module evaluation while the parent only
|
|
23
|
+
// waits for dep *files*. The default export is the single plugin — registered
|
|
24
|
+
// globally via bunfig preload and loaded by `[serve.static] plugins` for
|
|
25
|
+
// `Bun.serve` HTML routes.
|
|
26
|
+
|
|
27
|
+
const ROOT = process.cwd();
|
|
28
|
+
const NS = 'godot';
|
|
29
|
+
|
|
30
|
+
/** Format metadata: `cls` = Godot class (importable), `scan` = scan text refs, `native` = no `.import` sidecar. */
|
|
31
|
+
const FORMATS: Record<string, { cls?: string; scan?: boolean; native?: boolean }> = {
|
|
32
|
+
gltf: { cls: 'PackedScene', scan: true },
|
|
33
|
+
tscn: { cls: 'PackedScene', scan: true, native: true },
|
|
34
|
+
obj: { cls: 'PackedScene', scan: true },
|
|
35
|
+
jpg: { cls: 'CompressedTexture2D' },
|
|
36
|
+
jpeg: { cls: 'CompressedTexture2D' },
|
|
37
|
+
png: { cls: 'CompressedTexture2D' },
|
|
38
|
+
webp: { cls: 'CompressedTexture2D' },
|
|
39
|
+
svg: { cls: 'Texture2D' },
|
|
40
|
+
exr: { cls: 'TextureLayered' },
|
|
41
|
+
hdr: { cls: 'TextureLayered' },
|
|
42
|
+
wav: { cls: 'AudioStreamWAV' },
|
|
43
|
+
ogg: { cls: 'AudioStreamOggVorbis' },
|
|
44
|
+
mp3: { cls: 'AudioStreamMP3' },
|
|
45
|
+
tres: { cls: 'Resource', scan: true, native: true },
|
|
46
|
+
po: { cls: 'Translation', native: true },
|
|
47
|
+
mtl: { scan: true },
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const ASSET_RE = new RegExp(
|
|
51
|
+
`\\.(${Object.entries(FORMATS).filter(([, f]) => f.cls).map(([k]) => k).join('|')})$`,
|
|
52
|
+
'i',
|
|
53
|
+
);
|
|
54
|
+
const CLASS_BY_EXT: Record<string, string> = Object.fromEntries(
|
|
55
|
+
Object.entries(FORMATS).filter(([, f]) => f.cls).map(([k, f]) => [k, f.cls!]),
|
|
56
|
+
);
|
|
57
|
+
const SCANNED = new Set(Object.entries(FORMATS).filter(([, f]) => f.scan).map(([k]) => k));
|
|
58
|
+
const NATIVE = new Set(Object.entries(FORMATS).filter(([, f]) => f.native).map(([k]) => k));
|
|
59
|
+
|
|
60
|
+
const extOf = (abs: string) => abs.slice(abs.lastIndexOf('.') + 1).toLowerCase();
|
|
61
|
+
const resOf = (abs: string) => 'res://' + abs.slice(ROOT.length + 1).replaceAll('\\', '/');
|
|
62
|
+
const isScannableText = (abs: string) => SCANNED.has(extOf(abs));
|
|
63
|
+
const isNativeText = (abs: string) => NATIVE.has(extOf(abs));
|
|
64
|
+
const isSourceAsset = (abs: string) => existsSync(abs + '.import') || isNativeText(abs);
|
|
65
|
+
|
|
66
|
+
/** Reads the imported destination (`dest_files`) out of an `.import` sidecar. */
|
|
67
|
+
function parseSidecarDest(text: string): string | null {
|
|
68
|
+
const deps = text.match(/^dest_files=\[([^\]]*)\]/m);
|
|
69
|
+
if (deps) {
|
|
70
|
+
const first = deps[1].match(/"([^"]+)"/);
|
|
71
|
+
if (first) {
|
|
72
|
+
return first[1];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const remap = text.match(/^path="([^"]+)"/m);
|
|
76
|
+
return remap ? remap[1] : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Generalized reference extraction for text-based source assets: absolute
|
|
80
|
+
// `res://` paths (Godot scenes/resources) and relative path tokens ending in a
|
|
81
|
+
// known asset extension (glTF uris, OBJ/MTL textures). The literal dot before
|
|
82
|
+
// the extension avoids matching MIME types inside `data:` URIs.
|
|
83
|
+
const RES_RE = /res:\/\/[\w./\\-]+/g;
|
|
84
|
+
const REL_RE = new RegExp(`([\\w./\\\\-]+\\.(?:${Object.keys(FORMATS).join('|')}))`, 'g');
|
|
85
|
+
|
|
86
|
+
function extractRefs(text: string): string[] {
|
|
87
|
+
const refs: string[] = [];
|
|
88
|
+
for (const m of text.matchAll(RES_RE)) refs.push(m[0]);
|
|
89
|
+
for (const m of text.matchAll(REL_RE)) refs.push(m[1]);
|
|
90
|
+
return refs;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Adds a source asset's own files to `files`: imported assets contribute their
|
|
95
|
+
* `.import` sidecar (text) + imported product (file); native text formats
|
|
96
|
+
* (tscn/tres/po) contribute the file itself (text).
|
|
97
|
+
*/
|
|
98
|
+
function addSource(srcAbs: string, files: Map<string, 'text' | 'file'>): void {
|
|
99
|
+
const impAbs = srcAbs + '.import';
|
|
100
|
+
if (existsSync(impAbs)) {
|
|
101
|
+
files.set(resOf(srcAbs) + '.import', 'text');
|
|
102
|
+
const dest = parseSidecarDest(readFileSync(impAbs, 'utf8'));
|
|
103
|
+
if (dest) {
|
|
104
|
+
files.set(dest, 'file');
|
|
105
|
+
}
|
|
106
|
+
} else if (isNativeText(srcAbs)) {
|
|
107
|
+
files.set(resOf(srcAbs), 'text');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Collects the referenced source assets (deps) of a text source: source assets
|
|
113
|
+
* become dep modules (each handles its own transitive deps); raw text formats
|
|
114
|
+
* without a sidecar (`.mtl`) are recursed into. Buffers (`.bin`) are skipped.
|
|
115
|
+
*/
|
|
116
|
+
function collectDeps(abs: string, visited: Set<string>, deps: string[]): void {
|
|
117
|
+
if (visited.has(abs)) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
visited.add(abs);
|
|
121
|
+
if (!isScannableText(abs)) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
for (const ref of extractRefs(readFileSync(abs, 'utf8'))) {
|
|
125
|
+
const refAbs = ref.startsWith('res://')
|
|
126
|
+
? resolve(ROOT, ref.slice('res://'.length))
|
|
127
|
+
: resolve(dirname(abs), ref);
|
|
128
|
+
if (visited.has(refAbs)) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (isSourceAsset(refAbs)) {
|
|
132
|
+
deps.push(refAbs);
|
|
133
|
+
} else if (isScannableText(refAbs)) {
|
|
134
|
+
collectDeps(refAbs, visited, deps);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function analyze(abs: string): { files: Map<string, 'text' | 'file'>; deps: string[] } {
|
|
140
|
+
const files = new Map<string, 'text' | 'file'>();
|
|
141
|
+
const deps: string[] = [];
|
|
142
|
+
addSource(abs, files);
|
|
143
|
+
collectDeps(abs, new Set(), deps);
|
|
144
|
+
return { files, deps };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function ensureModule(abs: string): string {
|
|
148
|
+
const res = resOf(abs);
|
|
149
|
+
const cls = CLASS_BY_EXT[extOf(abs)] ?? 'Resource';
|
|
150
|
+
const { files, deps } = analyze(abs);
|
|
151
|
+
|
|
152
|
+
const imports = [
|
|
153
|
+
`import { loadAsset } from '@ringozz/godot/load';`,
|
|
154
|
+
`import { ${cls} } from '@ringozz/godot/${cls}';`,
|
|
155
|
+
];
|
|
156
|
+
const map: string[] = [];
|
|
157
|
+
let ti = 0;
|
|
158
|
+
let fi = 0;
|
|
159
|
+
for (const [resPath, kind] of files) {
|
|
160
|
+
const absP = resolve(ROOT, resPath.slice('res://'.length));
|
|
161
|
+
const v = kind === 'text' ? `c${ti++}` : `f${fi++}`;
|
|
162
|
+
const type = kind === 'text' ? 'text' : 'file';
|
|
163
|
+
const field = kind === 'text' ? 'content' : 'path';
|
|
164
|
+
imports.push(`import ${v} from ${JSON.stringify(absP)} with { type: '${type}' };`);
|
|
165
|
+
map.push(` ${JSON.stringify(resPath)}: { ${field}: ${v} },`);
|
|
166
|
+
}
|
|
167
|
+
deps.forEach((dep, i) => imports.push(`import * as dep${i} from ${JSON.stringify(dep)};`));
|
|
168
|
+
|
|
169
|
+
const filesVar = files.size ? `const files = {\n${map.join('\n')}\n};` : `const files = {};`;
|
|
170
|
+
const loadAsset = `const { materialize, load } = loadAsset(${JSON.stringify(res)}, ${cls}, files, [${deps.map((_, i) => `dep${i}`).join(', ')}], import.meta.hot.data);`;
|
|
171
|
+
return imports.join('\n') + '\n' + filesVar + '\n' + loadAsset + '\n' + `export { materialize, load as default };` + '\n';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const assetPlugin: BunPlugin = {
|
|
175
|
+
name: 'godot-assets',
|
|
176
|
+
setup(build) {
|
|
177
|
+
build.onResolve({ filter: ASSET_RE }, (args) => {
|
|
178
|
+
const importerAbs = args.importer?.startsWith(`${NS}:`)
|
|
179
|
+
? args.importer.slice(NS.length + 1)
|
|
180
|
+
: args.importer;
|
|
181
|
+
const base = importerAbs ? dirname(importerAbs) : (args.resolveDir ?? ROOT);
|
|
182
|
+
const abs = resolve(base, args.path);
|
|
183
|
+
// A module's own native-text source (e.g. `.tscn`) self-imports the
|
|
184
|
+
// same path — let it load as a real file.
|
|
185
|
+
if (abs === importerAbs) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
return { path: abs, namespace: NS };
|
|
189
|
+
});
|
|
190
|
+
build.onLoad({ filter: /.*/, namespace: NS }, (args) => ({
|
|
191
|
+
contents: ensureModule(args.path),
|
|
192
|
+
loader: 'js',
|
|
193
|
+
}));
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
export default assetPlugin;
|
|
198
|
+
plugin(assetPlugin);
|
package/src/runtime.ts
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
Copyright (c) Vladimir Davidovich. All rights reserved.
|
|
3
3
|
***********************************************************************/
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const { default: _mod } =
|
|
5
|
+
/** Shared runtime: platform-specific native-module selection lives in
|
|
6
|
+
* `boot.ts` (desktop) / `boot.browser.ts` (web, via the package's
|
|
7
|
+
* `browser` field). `_mod` is the native module — the desktop addon's
|
|
8
|
+
* `module.exports` or the wasm module's `.default`.
|
|
9
|
+
*/
|
|
10
|
+
import { getNativeModule } from './boot.ts';
|
|
11
|
+
const { default: _mod } = getNativeModule() as any;
|
|
12
12
|
|
|
13
13
|
/** Godot instance */
|
|
14
14
|
import type { GodotInstance } from '../gen/classes/GodotInstance.ts';
|
|
@@ -42,6 +42,9 @@ export const _set = _mod._set as (target: GodotVar, nameId: number, val: unknown
|
|
|
42
42
|
/** Register Godot object wrapper. */
|
|
43
43
|
export const _R = _mod._R as (typeId: number, ctor: Function) => string;
|
|
44
44
|
|
|
45
|
+
/** Stage bytes into the web MEMFS (Godot's `copyToFS`); undefined on desktop. */
|
|
46
|
+
export const stageFile = (_mod as any).stageFile as ((path: string, bytes: Uint8Array) => void) | undefined;
|
|
47
|
+
|
|
45
48
|
/** Get a Signal value from an Object by signal name, or connect a callback when 3rd arg given. */
|
|
46
49
|
export const _G = _mod._G as (target: GodotVar, signalNameId: number, callback?: unknown) => unknown;
|
|
47
50
|
|
package/src/web-image.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**********************************************************************
|
|
2
|
+
Copyright (c) Vladimir Davidovich. All rights reserved.
|
|
3
|
+
***********************************************************************/
|
|
4
|
+
|
|
5
|
+
// Web-only: decode the embedded PNG/WebP blobs of a `.ctex` CompressedTexture2D
|
|
6
|
+
// container with the browser's `createImageBitmap`, and rebuild the container as
|
|
7
|
+
// a raw RGBA8 `DATA_FORMAT_IMAGE` ctex — so no image codec runs in wasm on web.
|
|
8
|
+
// Anything that isn't a PNG/WebP-embedded ctex is returned unchanged.
|
|
9
|
+
|
|
10
|
+
const CTEX_MAGIC = 'GST2';
|
|
11
|
+
const DATA_FORMAT_IMAGE = 0;
|
|
12
|
+
const DATA_FORMAT_PNG = 1;
|
|
13
|
+
const DATA_FORMAT_WEBP = 2;
|
|
14
|
+
const FORMAT_RGBA8 = 5;
|
|
15
|
+
const FORMAT_BIT_STREAM = 1 << 22;
|
|
16
|
+
|
|
17
|
+
// Outer header: magic(4) version(4) w(4) h(4) df(4) mipmap_limit(4) reserved(12).
|
|
18
|
+
const OUTER_HEADER_SIZE = 36;
|
|
19
|
+
// Sub-header: data_format(4) w(2) h(2) mipmaps(4) format(4).
|
|
20
|
+
const SUB_HEADER_OFFSET = OUTER_HEADER_SIZE;
|
|
21
|
+
const SUB_HEADER_SIZE = 16;
|
|
22
|
+
const BLOB_TABLE_OFFSET = SUB_HEADER_OFFSET + SUB_HEADER_SIZE;
|
|
23
|
+
|
|
24
|
+
interface DecodedBitmap {
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
data: Uint8Array;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function decodeWebBitmap(blob: Uint8Array<ArrayBuffer>, type: string): Promise<DecodedBitmap> {
|
|
31
|
+
const bmp = await createImageBitmap(new Blob([blob], { type }), {
|
|
32
|
+
imageOrientation: 'none',
|
|
33
|
+
colorSpaceConversion: 'none',
|
|
34
|
+
premultiplyAlpha: 'none',
|
|
35
|
+
});
|
|
36
|
+
try {
|
|
37
|
+
const canvas = new OffscreenCanvas(bmp.width, bmp.height);
|
|
38
|
+
const ctx = canvas.getContext('2d', { colorSpace: 'srgb', willReadFrequently: true }) as
|
|
39
|
+
| OffscreenCanvasRenderingContext2D
|
|
40
|
+
| null;
|
|
41
|
+
if (!ctx) {
|
|
42
|
+
throw new Error('web-image: no 2d context for OffscreenCanvas');
|
|
43
|
+
}
|
|
44
|
+
ctx.drawImage(bmp, 0, 0);
|
|
45
|
+
const img = ctx.getImageData(0, 0, bmp.width, bmp.height);
|
|
46
|
+
return { width: bmp.width, height: bmp.height, data: new Uint8Array(img.data.buffer, img.data.byteOffset, img.data.byteLength) };
|
|
47
|
+
} finally {
|
|
48
|
+
bmp.close();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Decodes the embedded PNG/WebP blobs of a `.ctex` file with the browser's
|
|
54
|
+
* `createImageBitmap` and rebuilds it as a raw RGBA8 (`DATA_FORMAT_IMAGE`)
|
|
55
|
+
* container. Non-ctex bytes, streamable textures, and `DATA_FORMAT_IMAGE` /
|
|
56
|
+
* `DATA_FORMAT_BASIS_UNIVERSAL` containers are returned unchanged. Throws if an
|
|
57
|
+
* embedded blob can't be decoded or its dimensions don't match the expected
|
|
58
|
+
* mipmap layout, so a would-be undecodable texture fails loudly at materialize
|
|
59
|
+
* time. A no-op outside browsers (no `createImageBitmap`).
|
|
60
|
+
*/
|
|
61
|
+
export async function decodeCtex(bytes: Uint8Array): Promise<Uint8Array> {
|
|
62
|
+
if (
|
|
63
|
+
typeof createImageBitmap !== 'function' ||
|
|
64
|
+
bytes.length < SUB_HEADER_OFFSET + SUB_HEADER_SIZE ||
|
|
65
|
+
String.fromCharCode(...bytes.subarray(0, 4)) !== CTEX_MAGIC
|
|
66
|
+
) {
|
|
67
|
+
return bytes;
|
|
68
|
+
}
|
|
69
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
70
|
+
if (dv.getUint32(16, true) & FORMAT_BIT_STREAM) {
|
|
71
|
+
return bytes; // streamable layout differs; leave it to the engine
|
|
72
|
+
}
|
|
73
|
+
const dataFormat = dv.getUint32(SUB_HEADER_OFFSET, true);
|
|
74
|
+
if (dataFormat !== DATA_FORMAT_PNG && dataFormat !== DATA_FORMAT_WEBP) {
|
|
75
|
+
return bytes; // raw and BASIS_UNIVERSAL containers pass through
|
|
76
|
+
}
|
|
77
|
+
const w = dv.getUint16(SUB_HEADER_OFFSET + 4, true);
|
|
78
|
+
const h = dv.getUint16(SUB_HEADER_OFFSET + 6, true);
|
|
79
|
+
const mipmaps = dv.getUint32(SUB_HEADER_OFFSET + 8, true);
|
|
80
|
+
const type = dataFormat === DATA_FORMAT_WEBP ? 'image/webp' : 'image/png';
|
|
81
|
+
|
|
82
|
+
let ofs = BLOB_TABLE_OFFSET;
|
|
83
|
+
const levels: Uint8Array[] = [];
|
|
84
|
+
for (let i = 0; i <= mipmaps; i++) {
|
|
85
|
+
if (ofs + 4 > bytes.length) {
|
|
86
|
+
throw new Error(`web-image: truncated ctex at mipmap ${i}`);
|
|
87
|
+
}
|
|
88
|
+
const size = dv.getUint32(ofs, true);
|
|
89
|
+
ofs += 4;
|
|
90
|
+
if (ofs + size > bytes.length) {
|
|
91
|
+
throw new Error(`web-image: truncated ctex blob at mipmap ${i}`);
|
|
92
|
+
}
|
|
93
|
+
const bmp = await decodeWebBitmap(bytes.slice(ofs, ofs + size), type);
|
|
94
|
+
ofs += size;
|
|
95
|
+
const ew = Math.max(w >> i, 1);
|
|
96
|
+
const eh = Math.max(h >> i, 1);
|
|
97
|
+
if (bmp.width !== ew || bmp.height !== eh) {
|
|
98
|
+
throw new Error(`web-image: mipmap ${i} decoded as ${bmp.width}x${bmp.height}, expected ${ew}x${eh}`);
|
|
99
|
+
}
|
|
100
|
+
levels.push(bmp.data);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const dataSize = levels.reduce((n, level) => n + level.length, 0);
|
|
104
|
+
const out = new Uint8Array(BLOB_TABLE_OFFSET + dataSize);
|
|
105
|
+
out.set(bytes.subarray(0, OUTER_HEADER_SIZE)); // outer header verbatim
|
|
106
|
+
const odv = new DataView(out.buffer);
|
|
107
|
+
odv.setUint32(SUB_HEADER_OFFSET, DATA_FORMAT_IMAGE, true);
|
|
108
|
+
odv.setUint16(SUB_HEADER_OFFSET + 4, w, true);
|
|
109
|
+
odv.setUint16(SUB_HEADER_OFFSET + 6, h, true);
|
|
110
|
+
odv.setUint32(SUB_HEADER_OFFSET + 8, mipmaps, true);
|
|
111
|
+
odv.setUint32(SUB_HEADER_OFFSET + 12, FORMAT_RGBA8, true);
|
|
112
|
+
let p = BLOB_TABLE_OFFSET;
|
|
113
|
+
for (const level of levels) {
|
|
114
|
+
out.set(level, p);
|
|
115
|
+
p += level.length;
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|