@ringozz/godot-web-wasm32 4.7.2-628 → 4.7.2-641

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 (3) hide show
  1. package/README.md +4 -0
  2. package/package.json +1 -1
  3. package/src/spark.ts +124 -30
package/README.md CHANGED
@@ -4,6 +4,10 @@ Godot Engine compiled to **WebAssembly** (`wasm32`, no threads), plus the web ad
4
4
 
5
5
  On web, `@ringozz/godot` dispatches here and this package initializes the engine with Emscripten + emnapi, binding it to a `<canvas>`.
6
6
 
7
+ ## Web texture transcoder
8
+
9
+ `@ringozz/godot-web-wasm32/spark` exports `createSparkTranscoder(helpers)`, the hook `@ringozz/godot`'s web `.ctex` staging installs. It rebuilds an imported texture's embedded PNG/WebP payloads as a device-native block-compressed container with `@ludicon/spark.js` (WebGPU), choosing the format from the staged resource path: normal maps → BC5/EAC-RG, alpha albedo and HDR/sky → BC7/ASTC, terrain weight planes → BC4/EAC-R, colour and ORM → BC1/ETC2 first (each chain falls back to BC7/ASTC, then to the raw RGBA8 decode). Every layer of an array/cubemap is encoded in parallel. The helpers are injected structurally so this package never imports `@ringozz/godot`; `@ludicon/spark.js`'s codec shaders carry a non-commercial EULA (the JS is MIT), so every install pulls it — see `@ringozz/godot`'s README for the licensing note.
10
+
7
11
  ## Symbolizing wasm stack traces
8
12
 
9
13
  The package ships a symbol map next to the wasm: `gen/godot.web.template_release.wasm32.nothreads.js.symbols`
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ringozz/godot-web-wasm32",
3
3
  "author": "Vladimir Davidovich",
4
- "version": "4.7.2-628",
4
+ "version": "4.7.2-641",
5
5
  "description": "Godot Engine build for @ringozz/godot",
6
6
  "publishConfig": {
7
7
  "access": "public"
package/src/spark.ts CHANGED
@@ -17,6 +17,10 @@
17
17
  // codec shaders carry a non-commercial EULA (the JS is MIT), so it is part of
18
18
  // every install — see the licensing note in `@ringozz/godot`'s README.
19
19
  //
20
+ // The block format is chosen from the staged resource path (see
21
+ // {@link pickSparkFormat}); a `.ctex` product keeps the source asset's name
22
+ // (`Rock05_Normal.png.webp-<hash>.ctex`), which is what the rules key on.
23
+ //
20
24
  // WebGPU, not spark's WebGL2 (SparkGL) path: WebGL2 has no
21
25
  // `getCompressedTexImage`, so sparkGL never exposes the encoded bytes (it reads
22
26
  // its block buffer back with `readPixels` internally). Only the WebGPU encoder's
@@ -59,25 +63,34 @@ export interface CtexHelpers {
59
63
  pickGodotFormats(): number[];
60
64
  mipChainLength(width: number, height: number): number;
61
65
  mipLevelDimensions(width: number, height: number, level: number): [number, number];
66
+ /** Bytes per 4x4 block of a Godot `Image::Format` (see `web-image.ts`). */
67
+ blockBytes(format: number): number;
62
68
  BLOB_TABLE_OFFSET: number;
69
+ FORMAT_DXT1: number;
70
+ FORMAT_RGTC_R: number;
71
+ FORMAT_RGTC_RG: number;
63
72
  FORMAT_BPTC_RGBA: number;
73
+ FORMAT_ETC2_R11: number;
74
+ FORMAT_ETC2_RG11: number;
75
+ FORMAT_ETC2_RGB8: number;
64
76
  FORMAT_ASTC_4x4: number;
65
77
  }
66
78
 
67
- /** The transcoder callback shape installed by `@ringozz/godot`'s `.ctex` staging. */
68
- export type SparkTranscoder = (bytes: Uint8Array) => Promise<Uint8Array | null>;
79
+ /**
80
+ * The transcoder callback shape installed by `@ringozz/godot`'s `.ctex` staging.
81
+ * `path` is the resource path being staged, used to pick the block format.
82
+ */
83
+ export type SparkTranscoder = (bytes: Uint8Array, path: string) => Promise<Uint8Array | null>;
69
84
 
70
- // Spark needs the exact WebGPU format string to allocate the output texture (it
71
- // validates the resolved format when `outputMipLevel` is given). Ordered
72
- // best-first; a target must be both uploadable by the device (Godot's caps, via
73
- // `pickGodotFormats`) and encodable by spark.
85
+ // One encodable block format: the spark name, the Godot `Image::Format` it maps
86
+ // to, and the exact WebGPU format string spark needs to allocate the output
87
+ // texture (validated when `outputMipLevel` is given).
74
88
  interface SparkTarget {
89
+ name: string;
75
90
  godot: number;
76
- spark: string;
77
91
  webgpu: GPUTextureFormat;
78
92
  }
79
93
 
80
- const BLOCK_BYTES = 16; // BC7 / ASTC 4x4
81
94
  // WebGPU usage flags (TS 7's lib.dom declares the `GPU*` types but not the value
82
95
  // namespaces, so spell out the fixed ABI values).
83
96
  const BUFFER_COPY_DST = 0x0008;
@@ -90,13 +103,28 @@ const MAP_MODE_READ = 0x0001;
90
103
  interface SparkContext {
91
104
  spark: Spark;
92
105
  device: GPUDevice;
93
- target: SparkTarget;
106
+ /** Device ∩ spark-supported targets, in no particular order. */
107
+ targets: SparkTarget[];
94
108
  }
95
109
 
96
- // Lazily created once per page; `null` means WebGPU (or a usable format) is
97
- // unavailable, so every transcode declines and the raw RGBA8 decode is used.
110
+ // Lazily created once per page; `null` means WebGPU is unavailable, so every
111
+ // transcode declines and the raw RGBA8 decode is used.
98
112
  let context: Promise<SparkContext | null> | null = null;
99
113
 
114
+ // The format table, built against the Godot constants core passes in.
115
+ function targetTable(ctex: CtexHelpers): SparkTarget[] {
116
+ return [
117
+ { name: 'bc1-rgb', godot: ctex.FORMAT_DXT1, webgpu: 'bc1-rgba-unorm' },
118
+ { name: 'bc4-r', godot: ctex.FORMAT_RGTC_R, webgpu: 'bc4-r-unorm' },
119
+ { name: 'bc5-rg', godot: ctex.FORMAT_RGTC_RG, webgpu: 'bc5-rg-unorm' },
120
+ { name: 'bc7-rgba', godot: ctex.FORMAT_BPTC_RGBA, webgpu: 'bc7-rgba-unorm' },
121
+ { name: 'eac-r', godot: ctex.FORMAT_ETC2_R11, webgpu: 'eac-r11unorm' },
122
+ { name: 'eac-rg', godot: ctex.FORMAT_ETC2_RG11, webgpu: 'eac-rg11unorm' },
123
+ { name: 'etc2-rgb', godot: ctex.FORMAT_ETC2_RGB8, webgpu: 'etc2-rgb8unorm' },
124
+ { name: 'astc-4x4-rgba', godot: ctex.FORMAT_ASTC_4x4, webgpu: 'astc-4x4-unorm' },
125
+ ];
126
+ }
127
+
100
128
  function getContext(ctex: CtexHelpers): Promise<SparkContext | null> {
101
129
  context ??= (async () => {
102
130
  const gpu = typeof navigator !== 'undefined' ? navigator.gpu : undefined;
@@ -111,12 +139,8 @@ function getContext(ctex: CtexHelpers): Promise<SparkContext | null> {
111
139
  const device = await adapter.requestDevice({ requiredFeatures: Spark.getRequiredFeatures(adapter) });
112
140
  const spark = await Spark.create(device);
113
141
  const caps = new Set(ctex.pickGodotFormats());
114
- const targets: SparkTarget[] = [
115
- { godot: ctex.FORMAT_BPTC_RGBA, spark: 'bc7-rgba', webgpu: 'bc7-rgba-unorm' },
116
- { godot: ctex.FORMAT_ASTC_4x4, spark: 'astc-rgba', webgpu: 'astc-4x4-unorm' },
117
- ];
118
- const target = targets.find((t) => caps.has(t.godot) && spark.isFormatSupported(t.spark));
119
- return target ? { spark, device, target } : null;
142
+ const targets = targetTable(ctex).filter((t) => caps.has(t.godot) && spark.isFormatSupported(t.name));
143
+ return { spark, device, targets };
120
144
  })().catch((err) => {
121
145
  console.warn('[godot] spark.js unavailable; web textures stay raw RGBA8:', err);
122
146
  return null;
@@ -124,6 +148,48 @@ function getContext(ctex: CtexHelpers): Promise<SparkContext | null> {
124
148
  return context;
125
149
  }
126
150
 
151
+ // ---------------------------------------------------------------------------
152
+ // File-name -> format rules, mirroring the three.js viewer's policy (Wartales):
153
+ // normals are 2-channel, alpha albedo and the HDR/sky magnitude stay RGBA,
154
+ // terrain weights are 1-channel, and colour + ORM use the smallest 8 B/block
155
+ // format.
156
+ // ---------------------------------------------------------------------------
157
+
158
+ // `(?<![a-z0-9])`/`(?![a-z0-9])` bound a role token to a name separator without
159
+ // matching it inside a word: `tile_normal`/`normal.webparray` match, `abnormal`
160
+ // and `normalize` do not.
161
+ const RGBA = ['bc7-rgba', 'astc-4x4-rgba'];
162
+ const COLOR = ['bc1-rgb', 'etc2-rgb', ...RGBA];
163
+
164
+ // Ordered, first match wins. The normal/weight chains keep an RGBA last resort
165
+ // so a role whose single/dual-channel format the device lacks still compresses
166
+ // (Godot rebuilds a normal's z; a weight shader reads `.r`).
167
+ const RULES: { re: RegExp; chain: string[] }[] = [
168
+ { re: /albedoalpha/i, chain: RGBA }, // cutout albedo, whose alpha is real
169
+ { re: /(?<![a-z0-9])(hdr|skybox|sky|panorama)(?![a-z0-9])/i, chain: RGBA }, // magnitude in alpha
170
+ { re: /(?<![a-z0-9])(normal|nrm|normalmap)s?(?![a-z0-9])/i, chain: ['bc5-rg', 'eac-rg', ...RGBA] },
171
+ { re: /(?<![a-z0-9])(splat|weight)s?(?![a-z0-9])/i, chain: ['bc4-r', 'eac-r', ...RGBA] },
172
+ ];
173
+
174
+ /**
175
+ * The block-format preference chain for a staged resource path, most preferred
176
+ * first. Pure, so it is unit-testable without WebGPU.
177
+ */
178
+ export function pickSparkFormat(path: string): string[] {
179
+ return RULES.find((rule) => rule.re.test(path))?.chain ?? COLOR;
180
+ }
181
+
182
+ // The first chain entry the device can host, or `null` to decline (raw RGBA8).
183
+ function pickTarget(ctx: SparkContext, path: string): SparkTarget | null {
184
+ for (const name of pickSparkFormat(path)) {
185
+ const target = ctx.targets.find((t) => t.name === name);
186
+ if (target) {
187
+ return target;
188
+ }
189
+ }
190
+ return null;
191
+ }
192
+
127
193
  const align256 = (n: number): number => (n + 255) & ~255;
128
194
 
129
195
  // Reads every mip level of `texture` into the container's data region. For
@@ -139,6 +205,7 @@ async function readInto(
139
205
  width: number,
140
206
  height: number,
141
207
  levels: number,
208
+ bytesPerBlock: number,
142
209
  ): Promise<void> {
143
210
  // Where each level sits in the readback buffer. Offsets/`bytesPerRow` must be
144
211
  // multiples of the block size; 256 satisfies that and any 4-byte rule.
@@ -149,7 +216,7 @@ async function readInto(
149
216
  const cols = Math.ceil(w / 4);
150
217
  const rows = Math.ceil(h / 4);
151
218
  const offset = align256(total);
152
- const stride = align256(cols * BLOCK_BYTES);
219
+ const stride = align256(cols * bytesPerBlock);
153
220
  layout.push({ offset, stride, cols, rows });
154
221
  total = offset + stride * rows;
155
222
  }
@@ -182,7 +249,7 @@ async function readInto(
182
249
  const view = new Uint8Array(buffer.getMappedRange());
183
250
  let p = dataOffset;
184
251
  for (const { offset, stride, cols, rows } of layout) {
185
- const tight = cols * BLOCK_BYTES;
252
+ const tight = cols * bytesPerBlock;
186
253
  if (stride === tight) {
187
254
  // Unpadded (a power-of-two-ish width): the level's blocks are
188
255
  // contiguous, so copy the whole level in one go rather than a
@@ -213,6 +280,7 @@ const encodable = (layer: { width: number; height: number }): boolean =>
213
280
  async function transcodeLayer(
214
281
  ctex: CtexHelpers,
215
282
  ctx: SparkContext,
283
+ target: SparkTarget,
216
284
  bytes: Uint8Array,
217
285
  layer: CtexLayer,
218
286
  out: Uint8Array,
@@ -225,7 +293,7 @@ async function transcodeLayer(
225
293
  const texture = ctx.device.createTexture({
226
294
  size: [width, height, 1],
227
295
  mipLevelCount: levels,
228
- format: ctx.target.webgpu,
296
+ format: target.webgpu,
229
297
  usage: TEXTURE_BINDING | TEXTURE_COPY_DST | TEXTURE_COPY_SRC,
230
298
  });
231
299
  try {
@@ -233,13 +301,23 @@ async function transcodeLayer(
233
301
  throw new Error(`layer decoded as ${bitmap.width}x${bitmap.height}, expected ${width}x${height}`);
234
302
  }
235
303
  await ctx.spark.encodeTexture(bitmap, {
236
- format: ctx.target.spark,
304
+ format: target.name,
237
305
  mips: true,
238
306
  mipmapCount: levels,
239
307
  outputTexture: texture,
240
308
  outputMipLevel: 0,
241
309
  });
242
- await readInto(ctex, ctx.device, texture, out, outOffset, width, height, levels);
310
+ await readInto(
311
+ ctex,
312
+ ctx.device,
313
+ texture,
314
+ out,
315
+ outOffset,
316
+ width,
317
+ height,
318
+ levels,
319
+ ctex.blockBytes(target.godot),
320
+ );
243
321
  } finally {
244
322
  bitmap.close();
245
323
  texture.destroy();
@@ -250,7 +328,7 @@ async function transcodeLayer(
250
328
  // own output texture), so the GPU queue stays fed and the per-layer round trips
251
329
  // overlap instead of stacking. Spark encodes 2D textures, so layers are separate
252
330
  // encodes; layer `i`'s `.ctex` body lands at `BLOB_TABLE_OFFSET + i * stride`.
253
- async function transcode(ctex: CtexHelpers, bytes: Uint8Array): Promise<Uint8Array | null> {
331
+ async function transcode(ctex: CtexHelpers, bytes: Uint8Array, path: string): Promise<Uint8Array | null> {
254
332
  const layers = ctex.parseCtex(bytes);
255
333
  if (!layers || !encodable(layers[0])) {
256
334
  return null; // not PNG/WebP-embedded, or a size spark would have to resize
@@ -259,12 +337,27 @@ async function transcode(ctex: CtexHelpers, bytes: Uint8Array): Promise<Uint8Arr
259
337
  if (!ctx) {
260
338
  return null;
261
339
  }
340
+ const target = pickTarget(ctx, path);
341
+ if (!target) {
342
+ return null; // no encodable format for this texture on this device
343
+ }
262
344
  const { width, height } = layers[0];
263
345
  const levels = ctex.mipChainLength(width, height);
264
- const { bytes: out, stride } = ctex.allocateCtex(bytes, layers.length, width, height, levels - 1, ctx.target.godot);
346
+ const { bytes: out, stride } = ctex.allocateCtex(bytes, layers.length, width, height, levels - 1, target.godot);
265
347
  await Promise.all(
266
348
  layers.map((layer, i) =>
267
- transcodeLayer(ctex, ctx, bytes, layer, out, ctex.BLOB_TABLE_OFFSET + i * stride, width, height, levels),
349
+ transcodeLayer(
350
+ ctex,
351
+ ctx,
352
+ target,
353
+ bytes,
354
+ layer,
355
+ out,
356
+ ctex.BLOB_TABLE_OFFSET + i * stride,
357
+ width,
358
+ height,
359
+ levels,
360
+ ),
268
361
  ),
269
362
  );
270
363
  return out;
@@ -272,9 +365,10 @@ async function transcode(ctex: CtexHelpers, bytes: Uint8Array): Promise<Uint8Arr
272
365
 
273
366
  /**
274
367
  * Builds the transcoder that `@ringozz/godot` installs for web `.ctex` staging,
275
- * given its container helpers. Any failure (no WebGPU, unsupported format, decode
276
- * problem) declines so the caller falls back to the raw RGBA8 decode; it never
277
- * throws.
368
+ * given its container helpers. The block format is chosen from the staged
369
+ * resource path ({@link pickSparkFormat}). Any failure (no WebGPU, no usable
370
+ * format, decode problem) declines so the caller falls back to the raw RGBA8
371
+ * decode; it never throws.
278
372
  */
279
373
  export function createSparkTranscoder(ctex: CtexHelpers): SparkTranscoder {
280
374
  // Kick off the WebGPU device (and the format probe) as soon as the encoder is
@@ -283,9 +377,9 @@ export function createSparkTranscoder(ctex: CtexHelpers): SparkTranscoder {
283
377
  // `.ctex` files behind device creation. `getContext` memoizes, and a failure
284
378
  // just declines every transcode (raw RGBA8 fallback).
285
379
  void getContext(ctex);
286
- return async (bytes) => {
380
+ return async (bytes, path) => {
287
381
  try {
288
- return await transcode(ctex, bytes);
382
+ return await transcode(ctex, bytes, path);
289
383
  } catch (err) {
290
384
  console.warn('[godot] spark transcode failed; using raw RGBA8:', err);
291
385
  return null;