@origingame/engine 0.0.0-stage → 0.1.0

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 (158) hide show
  1. package/bin/og.mjs +25 -0
  2. package/dist/web/origin-engine.d.ts +1233 -0
  3. package/dist/web/origin-engine.js +8628 -0
  4. package/dist/web/origin-engine_bg.wasm +0 -0
  5. package/dist/web/origin-engine_bg.wasm.d.ts +366 -0
  6. package/dist/web-publish/origin-engine-lanes.js +8509 -0
  7. package/dist/web-publish/origin-engine-lanes_bg.wasm +0 -0
  8. package/dist/web-publish/origin-engine.d.ts +1176 -0
  9. package/dist/web-publish/origin-engine.js +8313 -0
  10. package/dist/web-publish/origin-engine_bg.wasm +0 -0
  11. package/dist/web-publish/origin-engine_bg.wasm.d.ts +347 -0
  12. package/dist/web-publish/snippets/wasm-bindgen-rayon-38edf6e439f6d70d/src/workerHelpers.no-bundler.js +77 -0
  13. package/dist/web-publish-2d/origin-engine-lanes.js +8509 -0
  14. package/dist/web-publish-2d/origin-engine-lanes_bg.wasm +0 -0
  15. package/dist/web-publish-2d/origin-engine.d.ts +1176 -0
  16. package/dist/web-publish-2d/origin-engine.js +8313 -0
  17. package/dist/web-publish-2d/origin-engine_bg.wasm +0 -0
  18. package/dist/web-publish-2d/origin-engine_bg.wasm.d.ts +347 -0
  19. package/dist/web-publish-2d/snippets/wasm-bindgen-rayon-38edf6e439f6d70d/src/workerHelpers.no-bundler.js +77 -0
  20. package/knowledge/README.md +31 -0
  21. package/knowledge/knowledge.json +13 -0
  22. package/knowledge/looks/catalogue.json +1871 -0
  23. package/knowledge/package.json +33 -0
  24. package/knowledge/rules/platform.json +329 -0
  25. package/knowledge/scaffold/README.md +27 -0
  26. package/knowledge/scaffold/distribution.json +48 -0
  27. package/knowledge/scaffold/origin-engine.json +19 -0
  28. package/knowledge/scaffold/template/.origin/setup +14 -0
  29. package/knowledge/scaffold/template/LICENSES.md +23 -0
  30. package/knowledge/scaffold/template/README.md +37 -0
  31. package/knowledge/scaffold/template/assets/ai/guard.ogt +48 -0
  32. package/knowledge/scaffold/template/assets/audio/click.ogt +9 -0
  33. package/knowledge/scaffold/template/assets/fonts/NotoSansSC-Regular-subset.ttf.ogt +12 -0
  34. package/knowledge/scaffold/template/assets/fonts/OFL.txt +93 -0
  35. package/knowledge/scaffold/template/assets/looks/origin-game.ogt +12 -0
  36. package/knowledge/scaffold/template/assets/nav/yard.ogt +27 -0
  37. package/knowledge/scaffold/template/assets/scenes/main.ogt +34 -0
  38. package/knowledge/scaffold/template/assets/strings/en.ogt +14 -0
  39. package/knowledge/scaffold/template/assets/textures/stone_n.png.ogt +7 -0
  40. package/knowledge/scaffold/template/gitattributes +22 -0
  41. package/knowledge/scaffold/template/package.json +18 -0
  42. package/knowledge/scaffold/template/project.ogt +25 -0
  43. package/knowledge/scaffold/template/src/main.ts +246 -0
  44. package/knowledge/scaffold/template/tests/guard.og.ts +67 -0
  45. package/knowledge/scaffold/template/tests/smoke.og.ts +43 -0
  46. package/knowledge/scaffold/template/tsconfig.json +17 -0
  47. package/knowledge/scripts/lib.ts +203 -0
  48. package/knowledge/scripts/mount.ts +210 -0
  49. package/knowledge/scripts/pack.ts +24 -0
  50. package/knowledge/scripts/scaffold.ts +72 -0
  51. package/knowledge/scripts/validate.ts +749 -0
  52. package/knowledge/skills/og-ai/SKILL.md +386 -0
  53. package/knowledge/skills/og-audio/SKILL.md +191 -0
  54. package/knowledge/skills/og-build/SKILL.md +330 -0
  55. package/knowledge/skills/og-core/SKILL.md +285 -0
  56. package/knowledge/skills/og-feel/SKILL.md +385 -0
  57. package/knowledge/skills/og-game/SKILL.md +1479 -0
  58. package/knowledge/skills/og-i18n/SKILL.md +220 -0
  59. package/knowledge/skills/og-platform-sdk/SKILL.md +199 -0
  60. package/knowledge/skills/og-taste/SKILL.md +537 -0
  61. package/knowledge/skills/og-ui/SKILL.md +444 -0
  62. package/knowledge/wiki/ai.md +627 -0
  63. package/knowledge/wiki/anim.md +1187 -0
  64. package/knowledge/wiki/atmosphere.md +636 -0
  65. package/knowledge/wiki/audio.md +732 -0
  66. package/knowledge/wiki/camera.md +711 -0
  67. package/knowledge/wiki/commands.md +357 -0
  68. package/knowledge/wiki/game.md +220 -0
  69. package/knowledge/wiki/i18n.md +213 -0
  70. package/knowledge/wiki/index.md +1027 -0
  71. package/knowledge/wiki/input.md +1714 -0
  72. package/knowledge/wiki/load.md +1280 -0
  73. package/knowledge/wiki/math.md +166 -0
  74. package/knowledge/wiki/nav.md +1154 -0
  75. package/knowledge/wiki/physics.md +3142 -0
  76. package/knowledge/wiki/platform.md +857 -0
  77. package/knowledge/wiki/probe.md +1692 -0
  78. package/knowledge/wiki/reflect.md +285 -0
  79. package/knowledge/wiki/render.md +3511 -0
  80. package/knowledge/wiki/rng.md +393 -0
  81. package/knowledge/wiki/stream.md +376 -0
  82. package/knowledge/wiki/system.md +197 -0
  83. package/knowledge/wiki/systems.md +43 -0
  84. package/knowledge/wiki/terrain.md +727 -0
  85. package/knowledge/wiki/tilemap.md +422 -0
  86. package/knowledge/wiki/time.md +150 -0
  87. package/knowledge/wiki/transform.md +230 -0
  88. package/knowledge/wiki/tween.md +580 -0
  89. package/knowledge/wiki/ui.md +8000 -0
  90. package/knowledge/wiki/world.md +909 -0
  91. package/package.json +68 -4
  92. package/schema/api.schema.json +29675 -0
  93. package/shell/README.md +22 -0
  94. package/shell/brand/README.md +23 -0
  95. package/shell/brand/icon-192.png +0 -0
  96. package/shell/brand/icon-512.png +0 -0
  97. package/shell/brand/mark-on-paper.svg +6 -0
  98. package/shell/brand/origin-mark.svg +5 -0
  99. package/shell/brand/origin-motion.css +251 -0
  100. package/shell/brand/tokens.css +20 -0
  101. package/shell/brand.ts +132 -0
  102. package/shell/page.ts +715 -0
  103. package/shell/platform.ts +203 -0
  104. package/shell/player.ts +238 -0
  105. package/shell/shell.css +185 -0
  106. package/shell/sw.ts +177 -0
  107. package/shell/transport.ts +515 -0
  108. package/shell/wire.ts +369 -0
  109. package/src/e6.ts +69 -0
  110. package/src/env.d.ts +100 -0
  111. package/src/generated/abi.ts +980 -0
  112. package/src/index.ts +193 -0
  113. package/src/node/audio.ts +86 -0
  114. package/src/node/build.ts +109 -0
  115. package/src/node/device.ts +981 -0
  116. package/src/node/doctor.ts +60 -0
  117. package/src/node/index.ts +102 -0
  118. package/src/node/platform.ts +24 -0
  119. package/src/node/playtest-run.ts +249 -0
  120. package/src/node/png.ts +130 -0
  121. package/src/node/polish.ts +976 -0
  122. package/src/node/run.ts +373 -0
  123. package/src/node/web-build.ts +808 -0
  124. package/src/p2.ts +35 -0
  125. package/src/platform-local.ts +211 -0
  126. package/src/playtest.ts +131 -0
  127. package/src/runtime.ts +1509 -0
  128. package/src/systems/cameras.ts +247 -0
  129. package/src/ui-kit/bind.ts +141 -0
  130. package/src/ui-kit/huds.ts +303 -0
  131. package/src/ui-kit/index.ts +36 -0
  132. package/src/ui-kit/menus.ts +187 -0
  133. package/src/ui-kit/settings.ts +131 -0
  134. package/src/ui-kit/store.ts +222 -0
  135. package/src/ui-kit/story.ts +89 -0
  136. package/src/ui-kit/util.ts +106 -0
  137. package/src/web/a11y.ts +429 -0
  138. package/src/web/audio-worklet.ts +193 -0
  139. package/src/web/audio.ts +307 -0
  140. package/src/web/basis.ts +239 -0
  141. package/src/web/gamepad.ts +123 -0
  142. package/src/web/host.ts +166 -0
  143. package/src/web/input.ts +237 -0
  144. package/src/web/lanes.ts +21 -0
  145. package/src/web/play.ts +20 -0
  146. package/src/web/rpc-ring.ts +110 -0
  147. package/src/web/settle.ts +122 -0
  148. package/src/web/shell.ts +643 -0
  149. package/src/web/text-input.ts +248 -0
  150. package/src/web/worker.ts +1002 -0
  151. package/types/bindings.json +13654 -0
  152. package/types/index.d.ts +20090 -0
  153. package/vendor/basis/LICENSE +201 -0
  154. package/vendor/basis/NOTICE +15 -0
  155. package/vendor/basis/README.md +18 -0
  156. package/vendor/basis/basis_transcoder.js +19 -0
  157. package/vendor/basis/basis_transcoder.wasm +0 -0
  158. package/README.md +0 -3
@@ -0,0 +1,307 @@
1
+ // Audio on the web (docs/design.md §2.2, §4; crates/og-audio/README.md §Host
2
+ // contract). Two halves, like input:
3
+ //
4
+ // - Main thread (`AudioShell`): owns the AudioContext. It is created and
5
+ // resumed once, on the page's first gesture (the one audio unlock), loads
6
+ // the worklet (audio-worklet.ts, bundled), and hands the engine Worker
7
+ // either the SharedArrayBuffer ring the worklet reads (cross-origin
8
+ // isolated) or a MessagePort to the worklet (otherwise), with the
9
+ // context's sample rate, as a `{ type: "audio" }` message.
10
+ // - Engine Worker (`WorkerAudio`): on that message opens the engine's mixer
11
+ // at the context's rate (og_web_audio_open), and after every frame's ticks
12
+ // pumps the PCM those ticks cover (og_web_audio_pump) into the ring, or
13
+ // posts it to the port.
14
+
15
+ import {
16
+ PROCESSOR,
17
+ RING_CAPACITY,
18
+ RING_CHANNELS,
19
+ RING_HEADER,
20
+ RING_PRIME,
21
+ RING_READ,
22
+ RING_SAMPLE_RATE,
23
+ RING_UNDERRUNS,
24
+ RING_WRITE,
25
+ ringAvailable,
26
+ type WorkletOptions,
27
+ } from "./audio-worklet.ts";
28
+
29
+ // ── the ring (producer side) ───────────────────────────────────────────────
30
+
31
+ /** Ring capacity in frames (a power of two): 8192 is 170 ms at 48 kHz. */
32
+ export const AUDIO_RING_FRAMES = 8192;
33
+ /** Frames the worklet buffers before playing: 2048 is 43 ms at 48 kHz. */
34
+ export const AUDIO_PRIME_FRAMES = 2048;
35
+
36
+ /** A ring for `frames` (rounded up to a power of two) stereo frames at `sampleRate`. */
37
+ export function createAudioRing(sampleRate: number, frames = AUDIO_RING_FRAMES, prime = AUDIO_PRIME_FRAMES): SharedArrayBuffer {
38
+ let capacity = 1;
39
+ while (capacity < frames) capacity *= 2;
40
+ const ring = new SharedArrayBuffer(RING_HEADER * 4 + capacity * 2 * 4);
41
+ const header = new Int32Array(ring, 0, RING_HEADER);
42
+ header[RING_CAPACITY] = capacity;
43
+ header[RING_CHANNELS] = 2;
44
+ header[RING_SAMPLE_RATE] = sampleRate;
45
+ header[RING_PRIME] = Math.min(prime, capacity);
46
+ return ring;
47
+ }
48
+
49
+ /** Frames the producer may write now. */
50
+ export function audioRingFree(ring: SharedArrayBuffer): number {
51
+ const header = new Int32Array(ring, 0, RING_HEADER);
52
+ return header[RING_CAPACITY]! - ringAvailable(header);
53
+ }
54
+
55
+ /** Producer: appends interleaved stereo frames; returns how many fit. */
56
+ export function writeAudioRing(ring: SharedArrayBuffer, pcm: Float32Array): number {
57
+ const header = new Int32Array(ring, 0, RING_HEADER);
58
+ const data = new Float32Array(ring, RING_HEADER * 4);
59
+ const capacity = header[RING_CAPACITY]!;
60
+ const mask = capacity - 1;
61
+ const write = Atomics.load(header, RING_WRITE);
62
+ const frames = Math.min(pcm.length >> 1, capacity - ringAvailable(header));
63
+ for (let i = 0; i < frames; i++) {
64
+ const at = ((write + i) & mask) * 2;
65
+ data[at] = pcm[i * 2]!;
66
+ data[at + 1] = pcm[i * 2 + 1]!;
67
+ }
68
+ Atomics.store(header, RING_WRITE, (write + frames) | 0);
69
+ return frames;
70
+ }
71
+
72
+ /** Underrun frames the worklet has played so far, and frames buffered. */
73
+ export function audioRingState(ring: SharedArrayBuffer): { buffered: number; underruns: number; read: number } {
74
+ const header = new Int32Array(ring, 0, RING_HEADER);
75
+ return { buffered: ringAvailable(header), underruns: Atomics.load(header, RING_UNDERRUNS), read: Atomics.load(header, RING_READ) };
76
+ }
77
+
78
+ // ── main thread ────────────────────────────────────────────────────────────
79
+
80
+ /** The message the shell posts to the engine Worker once audio is unlocked. */
81
+ export interface AudioMessage {
82
+ readonly type: "audio";
83
+ readonly sampleRate: number;
84
+ readonly ring?: SharedArrayBuffer;
85
+ readonly port?: MessagePort;
86
+ /** The context's baseLatency plus outputLatency, seconds (what the device adds after the worklet). */
87
+ readonly latency?: number;
88
+ }
89
+
90
+ /** A later report of the context's latency (outputLatency moves as the device settles). */
91
+ export interface AudioLatencyMessage {
92
+ readonly type: "audio-latency";
93
+ readonly latency: number;
94
+ }
95
+
96
+ /** The context's own latency after the worklet: baseLatency and outputLatency, seconds (0 where a browser reports none). */
97
+ export function contextLatency(context: { baseLatency?: number; outputLatency?: number }): number {
98
+ const n = (v: number | undefined) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0);
99
+ return n(context.baseLatency) + n(context.outputLatency);
100
+ }
101
+
102
+ export interface AudioShellOptions {
103
+ /** The engine Worker. */
104
+ readonly worker: Worker;
105
+ /** URL of the bundled audio-worklet module. */
106
+ readonly worklet: string;
107
+ /** Where the first gesture is listened for (default: the window). */
108
+ readonly target?: EventTarget;
109
+ /** Use the ring when cross-origin isolated (default true). */
110
+ readonly ring?: boolean;
111
+ }
112
+
113
+ const GESTURES = ["pointerdown", "keydown", "touchend"] as const;
114
+
115
+ /**
116
+ * The page's audio: nothing until the first gesture, then one AudioContext,
117
+ * the worklet node, and the transport to the engine Worker.
118
+ */
119
+ export class AudioShell {
120
+ readonly #options: AudioShellOptions;
121
+ #context: AudioContext | null = null;
122
+ #node: AudioWorkletNode | null = null;
123
+ #ring: SharedArrayBuffer | null = null;
124
+ #unlocking: Promise<AudioContext> | null = null;
125
+ readonly #off: Array<() => void> = [];
126
+
127
+ constructor(options: AudioShellOptions) {
128
+ this.#options = options;
129
+ }
130
+
131
+ /** Unlock on the first pointer, key or touch gesture (once). */
132
+ listen(): void {
133
+ const target = this.#options.target ?? (globalThis as unknown as EventTarget);
134
+ const once = () => {
135
+ for (const off of this.#off.splice(0)) off();
136
+ void this.unlock();
137
+ };
138
+ for (const type of GESTURES) {
139
+ target.addEventListener(type, once, { capture: true });
140
+ this.#off.push(() => target.removeEventListener(type, once, { capture: true }));
141
+ }
142
+ }
143
+
144
+ /** Create and resume the AudioContext (call inside a user gesture). Idempotent. */
145
+ unlock(): Promise<AudioContext> {
146
+ this.#unlocking ??= this.#start();
147
+ return this.#unlocking;
148
+ }
149
+
150
+ async #start(): Promise<AudioContext> {
151
+ const context = new AudioContext({ latencyHint: "interactive" });
152
+ // resume() first: it must run inside the gesture's task.
153
+ const resumed = context.resume();
154
+ await context.audioWorklet.addModule(this.#options.worklet);
155
+ const isolated = (this.#options.ring ?? true) && globalThis.crossOriginIsolated === true;
156
+ const sampleRate = context.sampleRate;
157
+ let processorOptions: WorkletOptions;
158
+ let message: AudioMessage;
159
+ let channel: MessageChannel | null = null;
160
+ if (isolated) {
161
+ this.#ring = createAudioRing(sampleRate);
162
+ processorOptions = { ring: this.#ring };
163
+ message = { type: "audio", sampleRate, ring: this.#ring };
164
+ } else {
165
+ channel = new MessageChannel();
166
+ processorOptions = { prime: AUDIO_PRIME_FRAMES, maxQueued: Math.round(sampleRate / 2) };
167
+ message = { type: "audio", sampleRate, port: channel.port2 };
168
+ }
169
+ this.#node = new AudioWorkletNode(context, PROCESSOR, {
170
+ numberOfInputs: 0,
171
+ numberOfOutputs: 1,
172
+ outputChannelCount: [2],
173
+ processorOptions,
174
+ });
175
+ if (channel) this.#node.port.postMessage({ type: "port", port: channel.port1 }, [channel.port1]);
176
+ this.#node.connect(context.destination);
177
+ message = { ...message, latency: contextLatency(context) };
178
+ this.#options.worker.postMessage(message, message.port ? [message.port] : []);
179
+ await resumed;
180
+ this.#context = context;
181
+ // outputLatency is known once the context runs and moves with the device: report changes.
182
+ let told = message.latency ?? 0;
183
+ const report = () => {
184
+ const now = contextLatency(context);
185
+ if (Math.abs(now - told) < 0.0005) return;
186
+ told = now;
187
+ this.#options.worker.postMessage({ type: "audio-latency", latency: now } satisfies AudioLatencyMessage);
188
+ };
189
+ report();
190
+ const timer = setInterval(report, 2000);
191
+ this.#off.push(() => clearInterval(timer));
192
+ return context;
193
+ }
194
+
195
+ get context(): AudioContext | null {
196
+ return this.#context;
197
+ }
198
+
199
+ /** `ring`, `message`, or `locked` before the first gesture. */
200
+ get transport(): "ring" | "message" | "locked" {
201
+ if (!this.#context) return "locked";
202
+ return this.#ring ? "ring" : "message";
203
+ }
204
+
205
+ /** The ring's fill and underruns (ring transport only). */
206
+ ringState(): { buffered: number; underruns: number } | null {
207
+ return this.#ring ? audioRingState(this.#ring) : null;
208
+ }
209
+
210
+ /** Suspend or resume output (the engine keeps ticking; a pause menu uses og.Audio instead). */
211
+ async suspend(on: boolean): Promise<void> {
212
+ if (!this.#context) return;
213
+ if (on) await this.#context.suspend();
214
+ else await this.#context.resume();
215
+ }
216
+
217
+ async dispose(): Promise<void> {
218
+ for (const off of this.#off.splice(0)) off();
219
+ this.#node?.disconnect();
220
+ await this.#context?.close();
221
+ this.#context = null;
222
+ }
223
+ }
224
+
225
+ // ── engine Worker ──────────────────────────────────────────────────────────
226
+
227
+ /** The wasm-bindgen exports of crates/og-host-web/src/audio.rs. */
228
+ export interface WebAudioGlue {
229
+ og_web_audio_open(engine: number, sampleRate: number): string;
230
+ og_web_audio_pump(engine: number, maxFrames: number): Float32Array;
231
+ og_web_audio_stats(engine: number): string;
232
+ og_web_audio_close(engine: number): boolean;
233
+ og_web_audio_insert_qoa(engine: number, id: string, qoa: Uint8Array): string;
234
+ og_web_audio_latency(engine: number, seconds: number): string;
235
+ }
236
+
237
+ type Wire<T> = { ok: true; value: T } | { ok: false; reason: string };
238
+
239
+ /** Without a ring, the largest chunk one pump posts (a quarter second at 48 kHz). */
240
+ const MAX_CHUNK_FRAMES = 12_000;
241
+
242
+ /** The Worker side of an engine's audio output. */
243
+ export class WorkerAudio {
244
+ readonly #glue: WebAudioGlue;
245
+ readonly #engine: number;
246
+ readonly #ring: SharedArrayBuffer | null;
247
+ readonly #port: MessagePort | null;
248
+ /** Frames the ring had no room for (the worklet is behind). */
249
+ overflow = 0;
250
+
251
+ readonly #sampleRate: number;
252
+
253
+ private constructor(glue: WebAudioGlue, engine: number, message: AudioMessage) {
254
+ this.#glue = glue;
255
+ this.#engine = engine;
256
+ this.#ring = message.ring ?? null;
257
+ this.#port = message.port ?? null;
258
+ this.#sampleRate = message.sampleRate;
259
+ }
260
+
261
+ /** Open the engine's mixer at the context's rate, on the shell's `audio` message. */
262
+ static attach(glue: WebAudioGlue, engine: number, message: AudioMessage): Wire<WorkerAudio> {
263
+ const opened = JSON.parse(glue.og_web_audio_open(engine, message.sampleRate)) as Wire<number>;
264
+ if (!opened.ok) return opened;
265
+ const audio = new WorkerAudio(glue, engine, message);
266
+ audio.latency(message.latency ?? 0);
267
+ return { ok: true, value: audio };
268
+ }
269
+
270
+ /**
271
+ * The context's latency (`contextLatency`) arrived: og.audio.outputLatency
272
+ * is it plus the worklet's prime (the frames it buffers before it plays),
273
+ * written before the next tick. Call between frames.
274
+ */
275
+ latency(context: number): void {
276
+ const seconds = context + AUDIO_PRIME_FRAMES / Math.max(1, this.#sampleRate);
277
+ this.#glue.og_web_audio_latency(this.#engine, seconds);
278
+ }
279
+
280
+ /** After a frame's ticks: ship the audio they cover. Returns the frames shipped. */
281
+ pump(): number {
282
+ if (this.#ring) {
283
+ const pcm = this.#glue.og_web_audio_pump(this.#engine, audioRingFree(this.#ring));
284
+ const wrote = writeAudioRing(this.#ring, pcm);
285
+ this.overflow += (pcm.length >> 1) - wrote;
286
+ return wrote;
287
+ }
288
+ const pcm = this.#glue.og_web_audio_pump(this.#engine, MAX_CHUNK_FRAMES);
289
+ if (pcm.length > 0 && this.#port) this.#port.postMessage(pcm, [pcm.buffer]);
290
+ return pcm.length >> 1;
291
+ }
292
+
293
+ /** The mixer's counters. */
294
+ stats(): Wire<Record<string, number>> {
295
+ return JSON.parse(this.#glue.og_web_audio_stats(this.#engine)) as Wire<Record<string, number>>;
296
+ }
297
+
298
+ /** Add a sound (a cooked sound's QOA bytes); its handle is what AudioSource.sound takes. */
299
+ insertSound(id: string, qoa: Uint8Array): Wire<number> {
300
+ return JSON.parse(this.#glue.og_web_audio_insert_qoa(this.#engine, id, qoa)) as Wire<number>;
301
+ }
302
+
303
+ close(): void {
304
+ this.#glue.og_web_audio_close(this.#engine);
305
+ this.#port?.close();
306
+ }
307
+ }
@@ -0,0 +1,239 @@
1
+ // KTX2 / Basis Universal transcoding for the web host (og-cook README §What
2
+ // phase 2 owes: the Rust runtime has no Basis transcoder on wasm32). Cooked
3
+ // textures are KTX2 files (ETC1S colour, UASTC normal and linear maps, or
4
+ // lossless RGBA8 with zlib, which og-cook's runtime decodes itself); the engine
5
+ // Worker transcodes the Basis ones here with Binomial's transcoder
6
+ // (vendor/basis/, Apache-2.0) into the GPU format the tier supports.
7
+ //
8
+ // const basis = await loadBasisTranscoder();
9
+ // const format = pickFormat(compressionsFromWebGpu(adapter.features), "etc1s");
10
+ // const tex = transcodeKtx2(basis, ktx2Bytes, format); // { width, height, format, levels, ... }
11
+ // device.createTexture({ format: wgpuFormatName(tex.format, srgb), ... })
12
+ //
13
+ // Works in a (module) Worker and under bun. The transcoder's script is a
14
+ // classic Emscripten script, evaluated with the Function constructor as
15
+ // three.js's KTX2Loader does, so a page whose CSP forbids 'unsafe-eval' must
16
+ // allow it for the Worker or load the transcoder another way.
17
+
18
+ /**
19
+ * The texture formats this module transcodes to, named as og-render's
20
+ * `TextureFormat::name` names them: BC5 and EAC RG11 hold a normal map's x
21
+ * and y (the renderer rebuilds z).
22
+ */
23
+ export const TEXTURE_FORMATS = ["rgba8", "bc7", "bc5", "astc4x4", "etc2", "eacrg11"] as const;
24
+ export type TextureFormat = (typeof TEXTURE_FORMATS)[number];
25
+
26
+ /** GPU block compressions a device may support. */
27
+ export type Compression = "bc" | "astc" | "etc2";
28
+
29
+ /** What a cooked KTX2 holds: ETC1S (colour) or UASTC (normal and linear maps). */
30
+ export type BasisKind = "etc1s" | "uastc";
31
+
32
+ /** One level of the transcoder's KTX2 file. */
33
+ interface LevelInfo {
34
+ readonly origWidth: number;
35
+ readonly origHeight: number;
36
+ readonly width: number;
37
+ readonly height: number;
38
+ }
39
+
40
+ /** The part of the transcoder's `KTX2File` this module uses. */
41
+ interface Ktx2File {
42
+ isValid(): boolean;
43
+ getWidth(): number;
44
+ getHeight(): number;
45
+ getLevels(): number;
46
+ getFaces(): number;
47
+ getLayers(): number;
48
+ getHasAlpha(): boolean;
49
+ isUASTC(): boolean;
50
+ isETC1S(): boolean;
51
+ getDFDTransferFunc(): number;
52
+ startTranscoding(): boolean;
53
+ getImageLevelInfo(level: number, layer: number, face: number): LevelInfo;
54
+ getImageTranscodedSizeInBytes(level: number, layer: number, face: number, format: number): number;
55
+ transcodeImage(dst: Uint8Array, level: number, layer: number, face: number, format: number, getAlphaForOpaque: number, channel0: number, channel1: number): number;
56
+ close(): void;
57
+ delete(): void;
58
+ }
59
+
60
+ /** The initialised transcoder module. */
61
+ export interface BasisTranscoder {
62
+ readonly KTX2File: new (bytes: Uint8Array) => Ktx2File;
63
+ readonly transcoder_texture_format?: Record<string, { value: number }>;
64
+ initializeBasis(): void;
65
+ }
66
+
67
+ type Factory = (module: { wasmBinary?: ArrayBuffer | Uint8Array; locateFile?: (path: string) => string }) => Promise<BasisTranscoder>;
68
+
69
+ /** basisu `transcoder_texture_format` values, used when the module does not export the enum. */
70
+ const TRANSCODER_FORMAT: Record<TextureFormat, { name: string; value: number }> = {
71
+ etc2: { name: "cTFETC2_RGBA", value: 1 },
72
+ bc5: { name: "cTFBC5_RG", value: 5 },
73
+ bc7: { name: "cTFBC7_RGBA", value: 6 },
74
+ eacrg11: { name: "cTFETC2_EAC_RG11", value: 21 },
75
+ astc4x4: { name: "cTFASTC_4x4_RGBA", value: 10 },
76
+ rgba8: { name: "cTFRGBA32", value: 13 },
77
+ };
78
+
79
+ /** The vendored transcoder's files, next to this module in the package. */
80
+ export const VENDORED = {
81
+ js: new URL("../../vendor/basis/basis_transcoder.js", import.meta.url).href,
82
+ wasm: new URL("../../vendor/basis/basis_transcoder.wasm", import.meta.url).href,
83
+ } as const;
84
+
85
+ async function bytesOf(source: string | URL | ArrayBuffer | Uint8Array): Promise<Uint8Array> {
86
+ if (source instanceof Uint8Array) return source;
87
+ if (source instanceof ArrayBuffer) return new Uint8Array(source);
88
+ const url = typeof source === "string" ? new URL(source, import.meta.url) : source;
89
+ if (url.protocol === "file:") {
90
+ // bun and node: fetch does not read file: URLs everywhere.
91
+ const { readFileSync } = await import("node:fs");
92
+ const { fileURLToPath } = await import("node:url");
93
+ return new Uint8Array(readFileSync(fileURLToPath(url)));
94
+ }
95
+ const response = await fetch(url);
96
+ if (!response.ok) throw new Error(`cannot fetch the Basis transcoder ${url.href}: HTTP ${response.status}`);
97
+ return new Uint8Array(await response.arrayBuffer());
98
+ }
99
+
100
+ /**
101
+ * Load and initialise the transcoder: `js` is the script (a URL, its text
102
+ * as bytes, or omitted for the vendored copy), `wasm` the module (a URL or
103
+ * its bytes, or omitted for the vendored copy).
104
+ */
105
+ export async function loadBasisTranscoder(
106
+ js: string | URL | ArrayBuffer | Uint8Array = VENDORED.js,
107
+ wasm: string | URL | ArrayBuffer | Uint8Array = VENDORED.wasm,
108
+ ): Promise<BasisTranscoder> {
109
+ const [script, binary] = await Promise.all([bytesOf(js), bytesOf(wasm)]);
110
+ const text = new TextDecoder().decode(script);
111
+ // The script defines `var BASIS` (a factory) and, under CommonJS, assigns
112
+ // module.exports: give it no `module`, so it stays a plain factory. Under
113
+ // bun or node it believes it is in node and asks for `fs` and `path`; it
114
+ // gets inert stand-ins, because the wasm bytes are handed over and it never
115
+ // reads a file.
116
+ const inert = (name: string) => new Proxy({}, { get: (_t, key) => () => { throw new Error(`the Basis transcoder tried ${name}.${String(key)}; it is given its wasm bytes and should not`); } });
117
+ const factory = new Function("module", "exports", "require", "__dirname", "__filename", `${text}\n;return BASIS;`)(undefined, undefined, inert, "", "") as Factory;
118
+ if (typeof factory !== "function") throw new Error("the Basis transcoder script did not define BASIS");
119
+ const basis = await factory({ wasmBinary: binary });
120
+ basis.initializeBasis();
121
+ return basis;
122
+ }
123
+
124
+ /** The block compressions a WebGPU adapter or device supports, from its features. */
125
+ export function compressionsFromWebGpu(features: { has(feature: string): boolean }): Set<Compression> {
126
+ const out = new Set<Compression>();
127
+ if (features.has("texture-compression-bc")) out.add("bc");
128
+ if (features.has("texture-compression-astc")) out.add("astc");
129
+ if (features.has("texture-compression-etc2")) out.add("etc2");
130
+ return out;
131
+ }
132
+
133
+ /** The block compressions a WebGL2 context supports, from `getSupportedExtensions()`. */
134
+ export function compressionsFromWebGl2(extensions: Iterable<string>): Set<Compression> {
135
+ const have = new Set(extensions);
136
+ const out = new Set<Compression>();
137
+ if (have.has("EXT_texture_compression_bptc")) out.add("bc");
138
+ if (have.has("WEBGL_compressed_texture_astc")) out.add("astc");
139
+ if (have.has("WEBGL_compressed_texture_etc")) out.add("etc2");
140
+ return out;
141
+ }
142
+
143
+ /**
144
+ * The GPU format to transcode to on this device: BC7 where BC is supported
145
+ * (desktop), else ASTC 4x4 (Apple, most Android), else ETC2 (other Android),
146
+ * else uncompressed RGBA8. UASTC normal maps and ETC1S colour maps use the
147
+ * same order: BC7 and ASTC keep UASTC's quality, and every choice keeps
148
+ * ETC1S's.
149
+ */
150
+ export function pickFormat(supported: ReadonlySet<Compression> | readonly Compression[], kind: BasisKind = "etc1s"): TextureFormat {
151
+ const has = (c: Compression) => (Array.isArray(supported) ? (supported as readonly Compression[]).includes(c) : (supported as ReadonlySet<Compression>).has(c));
152
+ void kind; // one order serves both kinds today; the parameter keeps call sites honest when it changes
153
+ if (has("bc")) return "bc7";
154
+ if (has("astc")) return "astc4x4";
155
+ if (has("etc2")) return "etc2";
156
+ return "rgba8";
157
+ }
158
+
159
+ /** The WebGPU texture format of a transcoded texture. */
160
+ export function wgpuFormatName(format: TextureFormat, srgb: boolean): string {
161
+ // The two-channel formats have no sRGB variant: normal maps are linear.
162
+ if (format === "bc5") return "bc5-rg-unorm";
163
+ if (format === "eacrg11") return "eac-rg11unorm";
164
+ const base = { rgba8: "rgba8unorm", bc7: "bc7-rgba-unorm", astc4x4: "astc-4x4-unorm", etc2: "etc2-rgba8unorm" }[format];
165
+ return srgb ? `${base}-srgb` : base;
166
+ }
167
+
168
+ /** A transcoded texture: every level of layer 0, face 0. */
169
+ export interface Transcoded {
170
+ /** Level 0's size in pixels. */
171
+ readonly width: number;
172
+ readonly height: number;
173
+ /** What `levels` hold: the requested format, or `rgba8` when a block format cannot hold this size. */
174
+ readonly format: TextureFormat;
175
+ /** Level data, level 0 first: RGBA8 rows top to bottom, or 4x4 blocks in row order. */
176
+ readonly levels: Uint8Array[];
177
+ /** Each level's size in pixels. */
178
+ readonly sizes: ReadonlyArray<{ readonly width: number; readonly height: number }>;
179
+ /** What the file held. */
180
+ readonly kind: BasisKind;
181
+ readonly alpha: boolean;
182
+ /** Whether the file's transfer function is sRGB (colour maps). */
183
+ readonly srgb: boolean;
184
+ }
185
+
186
+ function formatValue(basis: BasisTranscoder, format: TextureFormat): number {
187
+ const f = TRANSCODER_FORMAT[format];
188
+ return basis.transcoder_texture_format?.[f.name]?.value ?? f.value;
189
+ }
190
+
191
+ /** The 12-byte KTX2 identifier: «KTX 20»\r\n\x1a\n. */
192
+ const KTX2_MAGIC = [0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a];
193
+
194
+ /**
195
+ * Transcode a Basis Universal KTX2 file (ETC1S or UASTC) to `format`. A
196
+ * block format needs level 0 to be a multiple of 4 on both axes (WebGPU's
197
+ * rule); otherwise the texture comes back as `rgba8`. Throws with the reason
198
+ * for a file that is not a valid Basis KTX2 (a lossless RGBA8 cook is decoded
199
+ * by og-cook's runtime, not here).
200
+ */
201
+ export function transcodeKtx2(basis: BasisTranscoder, bytes: Uint8Array, format: TextureFormat): Transcoded {
202
+ // The transcoder's own isValid() accepts garbage that merely parses.
203
+ if (bytes.length < 80 || !KTX2_MAGIC.every((b, i) => bytes[i] === b)) throw new Error("not a KTX2 file: the identifier «KTX 20» is missing");
204
+ const file = new basis.KTX2File(bytes);
205
+ try {
206
+ if (!file.isValid()) throw new Error("not a KTX2 file the Basis transcoder accepts");
207
+ if (!file.isETC1S() && !file.isUASTC()) throw new Error("not a Basis Universal KTX2 (neither ETC1S nor UASTC): decode it with og-cook's runtime instead");
208
+ const width = file.getWidth();
209
+ const height = file.getHeight();
210
+ const chosen: TextureFormat = format !== "rgba8" && (width % 4 !== 0 || height % 4 !== 0) ? "rgba8" : format;
211
+ if (!file.startTranscoding()) throw new Error("the Basis transcoder could not start transcoding this file");
212
+ const value = formatValue(basis, chosen);
213
+ const levels: Uint8Array[] = [];
214
+ const sizes: Array<{ width: number; height: number }> = [];
215
+ for (let level = 0; level < file.getLevels(); level++) {
216
+ const info = file.getImageLevelInfo(level, 0, 0);
217
+ const size = file.getImageTranscodedSizeInBytes(level, 0, 0, value);
218
+ const dst = new Uint8Array(size);
219
+ if (!file.transcodeImage(dst, level, 0, 0, value, 0, -1, -1)) {
220
+ throw new Error(`the Basis transcoder failed on level ${level} (${info.origWidth}x${info.origHeight}) to ${chosen}`);
221
+ }
222
+ levels.push(dst);
223
+ sizes.push({ width: info.origWidth, height: info.origHeight });
224
+ }
225
+ return {
226
+ width,
227
+ height,
228
+ format: chosen,
229
+ levels,
230
+ sizes,
231
+ kind: file.isUASTC() ? "uastc" : "etc1s",
232
+ alpha: file.getHasAlpha(),
233
+ srgb: file.getDFDTransferFunc() === 2,
234
+ };
235
+ } finally {
236
+ file.close();
237
+ file.delete();
238
+ }
239
+ }
@@ -0,0 +1,123 @@
1
+ // Gamepads on the web (main thread): polled once per display frame. Each
2
+ // standard-mapping pad's state goes to the engine whole whenever it changes
3
+ // (a `gamepad` event: og.Gamepad, the first four pads), and, as a fallback
4
+ // for games that only read keys, as the keys of the mapping below (the same
5
+ // for every game; og-ui counts a pad intent and the key it maps to once).
6
+ //
7
+ // A (0) Space · B (1) Escape · X (2) KeyX · Y (3) KeyZ
8
+ // LB (4) KeyQ · RB (5) KeyE · Back (8) Tab · Start (9) Enter
9
+ // D-pad (12–15) and the left stick (past 0.5) ArrowUp/Down/Left/Right
10
+
11
+ import { GAMEPAD_BUTTONS, type InjectedInput } from "./input.ts";
12
+
13
+ export const GAMEPAD_KEYS: Readonly<Record<number, string>> = Object.freeze({
14
+ 0: "Space",
15
+ 1: "Escape",
16
+ 2: "KeyX",
17
+ 3: "KeyZ",
18
+ 4: "KeyQ",
19
+ 5: "KeyE",
20
+ 8: "Tab",
21
+ 9: "Enter",
22
+ 12: "ArrowUp",
23
+ 13: "ArrowDown",
24
+ 14: "ArrowLeft",
25
+ 15: "ArrowRight",
26
+ });
27
+
28
+ /** The stick deflection that counts as a direction. */
29
+ export const STICK_THRESHOLD = 0.5;
30
+
31
+ interface PadLike {
32
+ readonly connected: boolean;
33
+ readonly mapping: string;
34
+ readonly buttons: ReadonlyArray<{ readonly pressed: boolean; readonly value?: number }>;
35
+ readonly axes: ReadonlyArray<number>;
36
+ readonly index?: number;
37
+ }
38
+
39
+ /** The `gamepad` events that move the engine's pads from `before` to `now` (keyed by a state string). */
40
+ export function padStates(pads: ReadonlyArray<PadLike | null>, before: Map<number, string>): InjectedInput[] {
41
+ const out: InjectedInput[] = [];
42
+ const seen = new Set<number>();
43
+ pads.forEach((pad, i) => {
44
+ const index = pad?.index ?? i;
45
+ if (!pad || !pad.connected || pad.mapping !== "standard" || index > 3) return;
46
+ seen.add(index);
47
+ const round = (v: number | undefined) => Math.round((v ?? 0) * 1000) / 1000;
48
+ const event: InjectedInput = {
49
+ kind: "gamepad",
50
+ pad: index,
51
+ connected: true,
52
+ buttons: GAMEPAD_BUTTONS.filter((_, b) => pad.buttons[b]?.pressed),
53
+ axes: [0, 1, 2, 3].map((a) => round(pad.axes[a])),
54
+ triggers: [round(pad.buttons[6]?.value), round(pad.buttons[7]?.value)],
55
+ };
56
+ const key = JSON.stringify(event);
57
+ if (before.get(index) !== key) {
58
+ before.set(index, key);
59
+ out.push(event);
60
+ }
61
+ });
62
+ for (const index of [...before.keys()]) {
63
+ if (seen.has(index)) continue;
64
+ before.delete(index);
65
+ out.push({ kind: "gamepad", pad: index, connected: false, buttons: [], axes: [0, 0, 0, 0], triggers: [0, 0] });
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /** The keys a set of pads holds now (any pad holding a key holds it). */
71
+ export function heldKeys(pads: ReadonlyArray<PadLike | null>): Set<string> {
72
+ const held = new Set<string>();
73
+ for (const pad of pads) {
74
+ if (!pad || !pad.connected || pad.mapping !== "standard") continue;
75
+ for (const [index, key] of Object.entries(GAMEPAD_KEYS)) if (pad.buttons[Number(index)]?.pressed) held.add(key);
76
+ const [x = 0, y = 0] = pad.axes;
77
+ if (x <= -STICK_THRESHOLD) held.add("ArrowLeft");
78
+ if (x >= STICK_THRESHOLD) held.add("ArrowRight");
79
+ if (y <= -STICK_THRESHOLD) held.add("ArrowUp");
80
+ if (y >= STICK_THRESHOLD) held.add("ArrowDown");
81
+ }
82
+ return held;
83
+ }
84
+
85
+ /** The key events that move from `before` to `now`. */
86
+ export function keyEdges(before: ReadonlySet<string>, now: ReadonlySet<string>): InjectedInput[] {
87
+ const out: InjectedInput[] = [];
88
+ for (const key of now) if (!before.has(key)) out.push({ kind: "key", key, action: "down" });
89
+ for (const key of before) if (!now.has(key)) out.push({ kind: "key", key, action: "up" });
90
+ return out;
91
+ }
92
+
93
+ /** Poll the page's pads while any is connected; returns the stop function. */
94
+ export function pollGamepads(send: (event: InjectedInput) => void): () => void {
95
+ const nav = globalThis.navigator as { getGamepads?: () => ReadonlyArray<PadLike | null> } | undefined;
96
+ if (!nav?.getGamepads) return () => {};
97
+ let held = new Set<string>();
98
+ const states = new Map<number, string>();
99
+ let frame = 0;
100
+ let stopped = false;
101
+ const poll = () => {
102
+ frame = 0;
103
+ if (stopped) return;
104
+ const pads = nav.getGamepads!();
105
+ for (const e of padStates(pads, states)) send(e);
106
+ const now = heldKeys(pads);
107
+ for (const e of keyEdges(held, now)) send(e);
108
+ held = now;
109
+ if (nav.getGamepads!().some((p) => p?.connected)) frame = requestAnimationFrame(poll);
110
+ };
111
+ const connected = () => {
112
+ if (!frame) frame = requestAnimationFrame(poll);
113
+ };
114
+ addEventListener("gamepadconnected", connected);
115
+ connected();
116
+ return () => {
117
+ stopped = true;
118
+ removeEventListener("gamepadconnected", connected);
119
+ if (frame) cancelAnimationFrame(frame);
120
+ for (const e of keyEdges(held, new Set())) send(e);
121
+ for (const e of padStates([], states)) send(e);
122
+ };
123
+ }