@forgeax/engine-shader 0.1.2

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 (162) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +99 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/ShaderRegistry.d.ts +208 -0
  5. package/dist/ShaderRegistry.d.ts.map +1 -0
  6. package/dist/__tests__/atmosphere-background.test.d.ts +2 -0
  7. package/dist/__tests__/atmosphere-background.test.d.ts.map +1 -0
  8. package/dist/__tests__/atmosphere-cubemap-orientation.test.d.ts +2 -0
  9. package/dist/__tests__/atmosphere-cubemap-orientation.test.d.ts.map +1 -0
  10. package/dist/__tests__/atmosphere-preetham-numeric.test.d.ts +2 -0
  11. package/dist/__tests__/atmosphere-preetham-numeric.test.d.ts.map +1 -0
  12. package/dist/__tests__/atmosphere-preetham-red.test.d.ts +2 -0
  13. package/dist/__tests__/atmosphere-preetham-red.test.d.ts.map +1 -0
  14. package/dist/__tests__/atmosphere-preetham.test.d.ts +2 -0
  15. package/dist/__tests__/atmosphere-preetham.test.d.ts.map +1 -0
  16. package/dist/__tests__/atmosphere-three-r184-reference.test.d.ts +2 -0
  17. package/dist/__tests__/atmosphere-three-r184-reference.test.d.ts.map +1 -0
  18. package/dist/__tests__/bloom-fxaa-tonemap.unit.test.d.ts +2 -0
  19. package/dist/__tests__/bloom-fxaa-tonemap.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/builtin-texture-sampling-contract.test.d.ts +2 -0
  21. package/dist/__tests__/builtin-texture-sampling-contract.test.d.ts.map +1 -0
  22. package/dist/__tests__/default-standard-pbr-alpha.unit.test.d.ts +2 -0
  23. package/dist/__tests__/default-standard-pbr-alpha.unit.test.d.ts.map +1 -0
  24. package/dist/__tests__/deferred-lighting-ssao.test.d.ts +2 -0
  25. package/dist/__tests__/deferred-lighting-ssao.test.d.ts.map +1 -0
  26. package/dist/__tests__/fog-oracle.unit.test.d.ts +2 -0
  27. package/dist/__tests__/fog-oracle.unit.test.d.ts.map +1 -0
  28. package/dist/__tests__/fog-orthographic-ray.unit.test.d.ts +2 -0
  29. package/dist/__tests__/fog-orthographic-ray.unit.test.d.ts.map +1 -0
  30. package/dist/__tests__/fog-producer-matrix.integration.test.d.ts +2 -0
  31. package/dist/__tests__/fog-producer-matrix.integration.test.d.ts.map +1 -0
  32. package/dist/__tests__/hdrp-ssao-compile.test.d.ts +2 -0
  33. package/dist/__tests__/hdrp-ssao-compile.test.d.ts.map +1 -0
  34. package/dist/__tests__/ibl-irradiance.unit.test.d.ts +2 -0
  35. package/dist/__tests__/ibl-irradiance.unit.test.d.ts.map +1 -0
  36. package/dist/__tests__/ibl-sampling.unit.test.d.ts +2 -0
  37. package/dist/__tests__/ibl-sampling.unit.test.d.ts.map +1 -0
  38. package/dist/__tests__/lighting-punctual.unit.test.d.ts +2 -0
  39. package/dist/__tests__/lighting-punctual.unit.test.d.ts.map +1 -0
  40. package/dist/__tests__/material-artifact-dedup.unit.test.d.ts +2 -0
  41. package/dist/__tests__/material-artifact-dedup.unit.test.d.ts.map +1 -0
  42. package/dist/__tests__/material-builtins.unit.test.d.ts +2 -0
  43. package/dist/__tests__/material-builtins.unit.test.d.ts.map +1 -0
  44. package/dist/__tests__/material-contract.unit.test.d.ts +2 -0
  45. package/dist/__tests__/material-contract.unit.test.d.ts.map +1 -0
  46. package/dist/__tests__/material-derived-builtins.integration.test.d.ts +2 -0
  47. package/dist/__tests__/material-derived-builtins.integration.test.d.ts.map +1 -0
  48. package/dist/__tests__/public-surface.unit.test.d.ts +2 -0
  49. package/dist/__tests__/public-surface.unit.test.d.ts.map +1 -0
  50. package/dist/__tests__/scene-temporal.unit.test.d.ts +2 -0
  51. package/dist/__tests__/scene-temporal.unit.test.d.ts.map +1 -0
  52. package/dist/__tests__/shader-registry-transaction.unit.test.d.ts +2 -0
  53. package/dist/__tests__/shader-registry-transaction.unit.test.d.ts.map +1 -0
  54. package/dist/__tests__/shader.unit.test.d.ts +2 -0
  55. package/dist/__tests__/shader.unit.test.d.ts.map +1 -0
  56. package/dist/__tests__/sprite-lit-shader.test.d.ts +2 -0
  57. package/dist/__tests__/sprite-lit-shader.test.d.ts.map +1 -0
  58. package/dist/__tests__/sprite-variants.unit.test.d.ts +2 -0
  59. package/dist/__tests__/sprite-variants.unit.test.d.ts.map +1 -0
  60. package/dist/__tests__/taa-resolve.unit.test.d.ts +2 -0
  61. package/dist/__tests__/taa-resolve.unit.test.d.ts.map +1 -0
  62. package/dist/__tests__/transparent-pbr.unit.test.d.ts +2 -0
  63. package/dist/__tests__/transparent-pbr.unit.test.d.ts.map +1 -0
  64. package/dist/__tests__/vertex-color-variant.unit.test.d.ts +2 -0
  65. package/dist/__tests__/vertex-color-variant.unit.test.d.ts.map +1 -0
  66. package/dist/errors.d.ts +96 -0
  67. package/dist/errors.d.ts.map +1 -0
  68. package/dist/index.d.ts +44 -0
  69. package/dist/index.d.ts.map +1 -0
  70. package/dist/index.mjs +560 -0
  71. package/dist/index.mjs.map +1 -0
  72. package/dist/material/artifact-registry.d.ts +31 -0
  73. package/dist/material/artifact-registry.d.ts.map +1 -0
  74. package/dist/material/artifact-types.d.ts +13 -0
  75. package/dist/material/artifact-types.d.ts.map +1 -0
  76. package/dist/material-schemas.d.ts +8 -0
  77. package/dist/material-schemas.d.ts.map +1 -0
  78. package/dist/register-default-sprite-lit.d.ts +40 -0
  79. package/dist/register-default-sprite-lit.d.ts.map +1 -0
  80. package/dist/register-default-standard-pbr-skin.d.ts +34 -0
  81. package/dist/register-default-standard-pbr-skin.d.ts.map +1 -0
  82. package/dist/tonemap.d.ts +13 -0
  83. package/dist/tonemap.d.ts.map +1 -0
  84. package/dist/types.d.ts +47 -0
  85. package/dist/types.d.ts.map +1 -0
  86. package/package.json +58 -0
  87. package/src/ShaderRegistry.ts +519 -0
  88. package/src/__tests__/atmosphere-background.test.ts +33 -0
  89. package/src/__tests__/atmosphere-cubemap-orientation.test.ts +68 -0
  90. package/src/__tests__/atmosphere-preetham-numeric.test.ts +122 -0
  91. package/src/__tests__/atmosphere-preetham-red.test.ts +52 -0
  92. package/src/__tests__/atmosphere-preetham.test.ts +132 -0
  93. package/src/__tests__/atmosphere-three-r184-reference.test.ts +122 -0
  94. package/src/__tests__/bloom-fxaa-tonemap.unit.test.ts +31 -0
  95. package/src/__tests__/builtin-texture-sampling-contract.test.ts +56 -0
  96. package/src/__tests__/default-standard-pbr-alpha.unit.test.ts +19 -0
  97. package/src/__tests__/deferred-lighting-ssao.test.ts +114 -0
  98. package/src/__tests__/fog-oracle.unit.test.ts +132 -0
  99. package/src/__tests__/fog-orthographic-ray.unit.test.ts +62 -0
  100. package/src/__tests__/fog-producer-matrix.integration.test.ts +77 -0
  101. package/src/__tests__/hdrp-ssao-compile.test.ts +185 -0
  102. package/src/__tests__/ibl-irradiance.unit.test.ts +43 -0
  103. package/src/__tests__/ibl-sampling.unit.test.ts +40 -0
  104. package/src/__tests__/lighting-punctual.unit.test.ts +66 -0
  105. package/src/__tests__/manifest.fixture.json +22 -0
  106. package/src/__tests__/material-artifact-dedup.unit.test.ts +21 -0
  107. package/src/__tests__/material-builtins.unit.test.ts +111 -0
  108. package/src/__tests__/material-contract.unit.test.ts +79 -0
  109. package/src/__tests__/material-derived-builtins.integration.test.ts +30 -0
  110. package/src/__tests__/public-surface.unit.test.ts +9 -0
  111. package/src/__tests__/scene-temporal.unit.test.ts +32 -0
  112. package/src/__tests__/shader-registry-transaction.unit.test.ts +116 -0
  113. package/src/__tests__/shader.unit.test.ts +1909 -0
  114. package/src/__tests__/sprite-lit-shader.test.ts +240 -0
  115. package/src/__tests__/sprite-variants.unit.test.ts +209 -0
  116. package/src/__tests__/taa-resolve.unit.test.ts +32 -0
  117. package/src/__tests__/transparent-pbr.unit.test.ts +21 -0
  118. package/src/__tests__/vertex-color-variant.unit.test.ts +52 -0
  119. package/src/atmosphere-background.wgsl +80 -0
  120. package/src/atmosphere-cubemap.wgsl +72 -0
  121. package/src/atmosphere-ibl.wgsl +127 -0
  122. package/src/atmosphere-preetham.wgsl +214 -0
  123. package/src/bloom-blur.wgsl +89 -0
  124. package/src/bloom-bright.wgsl +50 -0
  125. package/src/bloom-composite.wgsl +54 -0
  126. package/src/brdf.wgsl +25 -0
  127. package/src/common.wgsl +523 -0
  128. package/src/default-standard-pbr-skin.wgsl +538 -0
  129. package/src/default-standard-pbr.wgsl +794 -0
  130. package/src/errors.ts +169 -0
  131. package/src/fog.wgsl +27 -0
  132. package/src/fxaa.wgsl +259 -0
  133. package/src/hdrp-cluster-forward.wgsl +186 -0
  134. package/src/hdrp-deferred-lighting.wgsl +69 -0
  135. package/src/hdrp-ssao.wgsl +174 -0
  136. package/src/ibl-brdf-lut.wgsl +92 -0
  137. package/src/ibl-equirect-to-cube.wgsl +56 -0
  138. package/src/ibl-irradiance.wgsl +89 -0
  139. package/src/ibl-prefilter.wgsl +101 -0
  140. package/src/ibl-sampling.wgsl +58 -0
  141. package/src/ibl-shared.wgsl +118 -0
  142. package/src/index.ts +166 -0
  143. package/src/lighting-directional.wgsl +471 -0
  144. package/src/lighting-punctual.wgsl +274 -0
  145. package/src/material/artifact-registry.ts +78 -0
  146. package/src/material/artifact-types.ts +26 -0
  147. package/src/material-schemas.ts +48 -0
  148. package/src/msdf-text.wgsl +208 -0
  149. package/src/pbr-temporal.wgsl +73 -0
  150. package/src/register-default-sprite-lit.ts +87 -0
  151. package/src/register-default-standard-pbr-skin.ts +74 -0
  152. package/src/shadow-pcf.wgsl +120 -0
  153. package/src/shadow_caster.wgsl +54 -0
  154. package/src/skybox.wgsl +75 -0
  155. package/src/sprite-lit.wgsl +307 -0
  156. package/src/sprite.wgsl +365 -0
  157. package/src/taa-resolve.wgsl +129 -0
  158. package/src/tbn.wgsl +54 -0
  159. package/src/tonemap.ts +13 -0
  160. package/src/tonemap.wgsl +177 -0
  161. package/src/types.ts +59 -0
  162. package/src/unlit.wgsl +186 -0
@@ -0,0 +1,519 @@
1
+ // @forgeax/engine-shader/ShaderRegistry — runtime shader registry, instance-per-engine shape.
2
+ //
3
+ // Shape rules (plan-strategy §S-10 / D-R10 / OQ-5 close + AC-03):
4
+ // - instance-per-engine — must be created via
5
+ // `new ShaderRegistry({device, manifestUrl})`; module-level singletons
6
+ // (`export const registry = ...`) and static methods (`static get`) are
7
+ // forbidden — aligned with the instance-based style of
8
+ // `Engine.create({ rhi })`.
9
+ // - Physical isolation — this file does **not** import @forgeax/engine-shader-compiler /
10
+ // @forgeax/engine-naga / @forgeax/engine-wgpu-wasm (guarded by the AC-06 triple-grep gate;
11
+ // feat-20260511-naga-rhi-wgpu-merge M4 replaced the legacy single-shim ban
12
+ // with the merged ban list).
13
+ // - Result model — `loadManifest()` / `get()` always go through Result.ok /
14
+ // Result.err and **never throw** (AGENTS.md "Errors are structured" / charter
15
+ // proposition 4: explicit failure).
16
+ // - 9-member error union — `get()` returns
17
+ // `Result<ShaderModule, RhiError | ShaderError>` (AGENTS.md "RHI / Shader /
18
+ // error-model contract").
19
+
20
+ import type { Result, RhiError, ShaderModule } from '@forgeax/engine-rhi';
21
+ import type {
22
+ ImmutableParamSchemaProjection,
23
+ ManifestEntry,
24
+ ParamSchemaEntry,
25
+ ParamSchemaProjectionOwnerStats,
26
+ } from '@forgeax/engine-types';
27
+ import { findUndeclaredSampledTextures, ParamSchemaProjectionOwner } from '@forgeax/engine-types';
28
+ import {
29
+ err,
30
+ manifestMalformed,
31
+ materialShaderNotFound,
32
+ ok,
33
+ type ShaderError,
34
+ type Result as ShaderResult,
35
+ shaderNotFound,
36
+ } from './errors.js';
37
+ import type { MaterialShaderManifestEntry } from './types.js';
38
+
39
+ // ─── Device dependency-injection interface ──────────────────────────────────────
40
+
41
+ /**
42
+ * Device dependency-injection interface — ShaderRegistry does not directly
43
+ * depend on `@forgeax/engine-rhi-webgpu` (physical isolation); the caller (engine or
44
+ * tests) supplies a device-like object that implements
45
+ * `createShaderModule(desc) → Result<ShaderModule, RhiError>`.
46
+ *
47
+ * Difference from the top-level async `createShaderModule(device, desc)` in
48
+ * `@forgeax/engine-rhi-webgpu`:
49
+ * - This interface is synchronous — so `registry.get()` can return a cached
50
+ * module synchronously.
51
+ * - The caller is responsible for wrapping the async real path into a sync one
52
+ * (e.g. the engine pre-compiles and caches modules during `loadManifest`).
53
+ *
54
+ * Mock test shape: a direct sync implementation (see `createMockDevice` in
55
+ * src/__tests__/registry.test.ts).
56
+ */
57
+ export interface ShaderRegistryDevice {
58
+ createShaderModule(desc: {
59
+ readonly code: string;
60
+ readonly label?: string | undefined;
61
+ }): Result<ShaderModule, RhiError>;
62
+ }
63
+
64
+ // ─── MaterialShader registry types (feat-20260523-shader-template-instance-split M5) ──
65
+
66
+ /**
67
+ * The `forgeax::` prefix reserves engine-shipped material shader identifiers
68
+ * (plan-strategy D-DefaultStandardPbr-Identifier + §8 charter F1 grep gate).
69
+ * AI users grep `forgeax::` to enumerate every reserved shader identifier in
70
+ * one shot. Custom user-side shaders pick their own identifier path
71
+ * (typically `<package>::<id>`) — anything that is not a `<guid>`
72
+ * (UUIDv5/v7) shape and not `forgeax::*`.
73
+ */
74
+ export const FORGEAX_RESERVED_PATH_PREFIX = 'forgeax::' as const;
75
+
76
+ /**
77
+ * Material shader admission input accepted by
78
+ * `installMaterialArtifact(identifier, entry)`. `findMaterialArtifact`
79
+ * returns a {@link RegisteredMaterialShaderEntry} with the immutable schema
80
+ * projection attached.
81
+ *
82
+ * - `source`: composed WGSL source (post-naga_oil); the final form fed to
83
+ * `device.createShaderModule({ code })` at pipeline-build time.
84
+ * - `paramSchema`: closed list of `{name, type, default?}` triples that the
85
+ * `MaterialAsset.payload.values` is validated against at register-time
86
+ * (feat-20260523-shader-template-instance-split M4 3-tier validation). Per
87
+ * feat-20260613-material-paramschema-driven-binding M3 / w12-w13, the
88
+ * paramSchema is also the SSOT for the pipeline BGL via
89
+ * `derive(paramSchema).bglEntries` — there is no longer a separate
90
+ * `bindingLayout` field on this entry (D-1 / D-2).
91
+ *
92
+ * The shape is symmetric across engine-default + user-custom paths
93
+ * (charter P4 consistent abstraction): both go through the same
94
+ * `installMaterialArtifact` API regardless of whether the host wires the
95
+ * default-standard-pbr triple at boot or the user registers a custom
96
+ * shader after asset import.
97
+ */
98
+ export interface MaterialShaderEntry {
99
+ readonly source: string;
100
+ readonly paramSchema: readonly ParamSchemaEntry[];
101
+ }
102
+
103
+ /** Registry-owned runtime shape published after immutable schema admission. */
104
+ export interface RegisteredMaterialShaderEntry extends MaterialShaderEntry {
105
+ readonly paramSchemaProjection: ImmutableParamSchemaProjection;
106
+ }
107
+
108
+ // ─── Public registry types ──────────────────────────────────────────────────────
109
+
110
+ export interface ShaderRegistryOptions {
111
+ readonly device: ShaderRegistryDevice;
112
+ /**
113
+ * URL the registry fetches `manifest.json` from. `undefined` puts the
114
+ * registry into the **zero-entry** mode: `loadManifest()` resolves
115
+ * `Result.ok(undefined)` without issuing a fetch and `entries()` returns
116
+ * an empty iterator. This is the bug-20260519 clear-pass-only path
117
+ * where AI users build a `Camera`-only world (no PBR / unlit pipeline
118
+ * is ever needed). `createRenderer.ts` Step 2 narrows on
119
+ * `manifestEntries.length > 0` to skip the dual `createShaderModule`
120
+ * compile in the same path (charter P3 + plan-strategy D-1 + D-2).
121
+ */
122
+ readonly manifestUrl: string | undefined;
123
+ }
124
+
125
+ // ─── ShaderRegistry main class (instance-per-engine) ============================
126
+
127
+ /**
128
+ * Runtime shader registry — content-addressable manifest lookup →
129
+ * `device.createShaderModule`.
130
+ *
131
+ * Usage:
132
+ * ```
133
+ * const registry = new ShaderRegistry({ device, manifestUrl: '/shaders/manifest.json' });
134
+ * const loaded = await registry.loadManifest();
135
+ * if (!loaded.ok) handleError(loaded.error); // ShaderError.manifest-malformed
136
+ *
137
+ * const result = registry.get('abc12345');
138
+ * if (!result.ok) handleError(result.error); // RhiError | ShaderError.shader-not-found
139
+ * else useModule(result.value);
140
+ * ```
141
+ *
142
+ * `static get` / module-level singletons are **forbidden** (plan-strategy
143
+ * §S-10).
144
+ */
145
+ export class ShaderRegistry {
146
+ readonly #device: ShaderRegistryDevice;
147
+ readonly #manifestUrl: string | undefined;
148
+ // hash → ManifestEntry index (populated after loadManifest).
149
+ readonly #entries = new Map<string, ManifestEntry>();
150
+ // hash → ShaderModule cache (populated lazily on first get()).
151
+ readonly #moduleCache = new Map<string, ShaderModule>();
152
+ // identifier → MaterialShaderEntry index (populated by
153
+ // installMaterialArtifact; feat-20260523-shader-template-instance-split M5 / T05).
154
+ readonly #materialShaders = new Map<string, RegisteredMaterialShaderEntry>();
155
+ readonly #paramSchemaProjectionOwner = new ParamSchemaProjectionOwner();
156
+ readonly #materialShaderManifestEntries: MaterialShaderManifestEntry[] = [];
157
+ #manifestLoaded = false;
158
+
159
+ constructor(opts: ShaderRegistryOptions) {
160
+ this.#device = opts.device;
161
+ this.#manifestUrl = opts.manifestUrl;
162
+ }
163
+
164
+ /**
165
+ * Loads manifest.json and populates the hash → ManifestEntry index.
166
+ *
167
+ * Failure paths (charter proposition 4: explicit failure):
168
+ * - fetch / data: URL parse failed → `Result.err(ShaderError.manifest-malformed)`
169
+ * - JSON.parse failed → ditto
170
+ * - schema missing the `entries` field / an entries element missing
171
+ * hash/wgsl/bindings → ditto
172
+ * - a material shader row is incomplete → ditto
173
+ *
174
+ * Validation is atomic: the registry does not publish any entry or
175
+ * material shader row until the complete document has been validated.
176
+ *
177
+ * Idempotent: subsequent calls reuse the first result (never re-fetch;
178
+ * charter proposition 6: idempotency).
179
+ */
180
+ async loadManifest(): Promise<ShaderResult<void, ShaderError>> {
181
+ if (this.#manifestLoaded) return ok(undefined);
182
+
183
+ // bug-20260519 D-2: zero-entry mode. `manifestUrl === undefined` means
184
+ // the host opted out of shipping a shader manifest (Camera-only /
185
+ // clear-pass-only LO 1.1 path); resolve `Result.ok` without issuing a
186
+ // fetch. `entries()` then yields nothing and createRenderer Step 2
187
+ // skips the dual `createShaderModule` compile (D-1 + D-3 nullable
188
+ // PipelineState). Subsequent `get(hash)` calls fall through to the
189
+ // shader-not-found arm via the empty entries map (charter P3
190
+ // explicit failure on misuse).
191
+ if (this.#manifestUrl === undefined) {
192
+ this.#manifestLoaded = true;
193
+ return ok(undefined);
194
+ }
195
+
196
+ let raw: string;
197
+ try {
198
+ const response = await fetch(this.#manifestUrl);
199
+ raw = await response.text();
200
+ } catch (e) {
201
+ return err(
202
+ manifestMalformed({
203
+ message: `ShaderRegistry: failed to fetch manifest at ${this.#manifestUrl}`,
204
+ hint: 'verify manifest URL is reachable; check bundler emitFile output path',
205
+ reason: e instanceof Error ? e.message : String(e),
206
+ }),
207
+ );
208
+ }
209
+
210
+ let parsed: unknown;
211
+ try {
212
+ parsed = JSON.parse(raw);
213
+ } catch (e) {
214
+ return err(
215
+ manifestMalformed({
216
+ message: 'ShaderRegistry: manifest JSON parse failed',
217
+ hint: 'manifest.json must be valid JSON; rebuild via @forgeax/engine-vite-plugin-shader generateBundle',
218
+ reason: e instanceof Error ? e.message : String(e),
219
+ }),
220
+ );
221
+ }
222
+
223
+ if (typeof parsed !== 'object' || parsed === null || !('entries' in parsed)) {
224
+ return err(
225
+ manifestMalformed({
226
+ message: 'ShaderRegistry: manifest missing required `entries` field',
227
+ hint: 'manifest schema requires top-level `entries: ManifestEntry[]`',
228
+ }),
229
+ );
230
+ }
231
+
232
+ const entries = (parsed as { entries: unknown }).entries;
233
+ if (!Array.isArray(entries)) {
234
+ return err(
235
+ manifestMalformed({
236
+ message: 'ShaderRegistry: manifest.entries is not an array',
237
+ hint: 'manifest schema requires `entries: ManifestEntry[]`',
238
+ }),
239
+ );
240
+ }
241
+
242
+ const validatedEntries: ManifestEntry[] = [];
243
+ for (const entry of entries) {
244
+ if (!isValidManifestEntry(entry)) {
245
+ return err(
246
+ manifestMalformed({
247
+ message: 'ShaderRegistry: manifest entry missing required fields',
248
+ hint: 'every entry needs {hash, wgsl, glsl, bindings} per @forgeax/engine-types.ManifestEntry',
249
+ reason: `bad entry: ${JSON.stringify(entry)}`,
250
+ }),
251
+ );
252
+ }
253
+ validatedEntries.push(entry);
254
+ }
255
+
256
+ // feat-20260526-pbr-uniform-fallback-no-storage-buffer M3 / w12:
257
+ // parse and store materialShaders[] from the manifest for
258
+ // createRenderer variant resolution. Non-existent / non-array
259
+ // materialShaders is treated as empty (backward-compatible with
260
+ // manifests from pre-M2 build runs).
261
+ const parsedSlim = parsed as { materialShaders?: unknown };
262
+ const validatedMaterialShaderEntries: MaterialShaderManifestEntry[] = [];
263
+ if (Array.isArray(parsedSlim.materialShaders)) {
264
+ for (const ms of parsedSlim.materialShaders) {
265
+ if (!isValidMaterialShaderManifestEntry(ms)) {
266
+ return err(
267
+ manifestMalformed({
268
+ message: 'ShaderRegistry: manifest material shader entry missing required fields',
269
+ hint: 'every material shader entry needs {identifier, sourcePath, composedWgsl, paramSchema, variants}',
270
+ reason: `bad material shader entry: ${JSON.stringify(ms)}`,
271
+ }),
272
+ );
273
+ }
274
+ validatedMaterialShaderEntries.push(ms);
275
+ }
276
+ }
277
+
278
+ this.#entries.clear();
279
+ for (const entry of validatedEntries) {
280
+ this.#entries.set(entry.hash, entry);
281
+ }
282
+ this.#materialShaderManifestEntries.length = 0;
283
+ this.#materialShaderManifestEntries.push(...validatedMaterialShaderEntries);
284
+ this.#manifestLoaded = true;
285
+ return ok(undefined);
286
+ }
287
+
288
+ /**
289
+ * Iterate every `ManifestEntry` populated by the most recent successful
290
+ * `loadManifest()` call. Order matches the manifest JSON `entries` array
291
+ * (insertion order; charter P2 structured-over-prose: AI users walk this
292
+ * to discover engine-shipped entries by content marker without
293
+ * re-fetching the manifest).
294
+ *
295
+ * Returns an empty iterator when no manifest has been loaded yet
296
+ * (charter P3 explicit failure: the empty case is not an error — the
297
+ * absent SSOT is the absent contract).
298
+ */
299
+ entries(): IterableIterator<ManifestEntry> {
300
+ return this.#entries.values();
301
+ }
302
+
303
+ /**
304
+ * Looks up a hash and forwards to `device.createShaderModule` (creates lazily
305
+ * on first hit, then caches).
306
+ *
307
+ * Error paths:
308
+ * - hash miss → `Result.err(ShaderError.shader-not-found)`
309
+ * - device.createShaderModule failed → `Result.err(RhiError)` passes through
310
+ * (it is **not** wrapped as a ShaderError).
311
+ *
312
+ * Sync shape: on cache miss, the underlying `device.createShaderModule` is
313
+ * called synchronously; the caller is responsible for handling the async
314
+ * real path at the device-wrapper layer (e.g. the engine pre-compiles
315
+ * during `loadManifest`).
316
+ */
317
+ get(hash: string): Result<ShaderModule, RhiError | ShaderError> {
318
+ const cachedModule = this.#moduleCache.get(hash);
319
+ if (cachedModule !== undefined) {
320
+ return ok(cachedModule);
321
+ }
322
+
323
+ const entry = this.#entries.get(hash);
324
+ if (entry === undefined) {
325
+ return err(
326
+ shaderNotFound({
327
+ hash,
328
+ hint: 'verify manifest.json contains the expected hash; rerun build to regenerate manifest',
329
+ }),
330
+ );
331
+ }
332
+
333
+ const result = this.#device.createShaderModule({ code: entry.wgsl, label: hash });
334
+ if (!result.ok) {
335
+ return result;
336
+ }
337
+ this.#moduleCache.set(hash, result.value);
338
+ return result;
339
+ }
340
+
341
+ // ─── MaterialShader registry surface ───────────────────────────────────────
342
+ // (feat-20260523-shader-template-instance-split M5 / T05 — plan-strategy
343
+ // D-DefaultStandardPbr-Identifier).
344
+ //
345
+ // Two-tier identifier namespace:
346
+ // - `forgeax::*` — engine-shipped reserved prefix; the host wires
347
+ // `forgeax::default-standard-pbr` at engine boot from the
348
+ // vite-plugin-shader manifest's `materialShaders[]` row + the
349
+ // `default-standard-pbr.schema.json` SSOT (M5 / T08 + M6).
350
+ // - any other identifier — user-side custom material shader, registered
351
+ // after asset import.
352
+ //
353
+ // The registry is the runtime SSOT consumed by:
354
+ // - the material decoder's `'::' two-way dispatch` (M4 / T01) — the path
355
+ // branch validates against `paramSchema` and feeds `MaterialSnapshot.
356
+ // materialShaderId` (M4 / T05).
357
+ // - `render-system-record`'s pipeline cache key (M4 / T06) — same
358
+ // identifier feeds the per-pipeline `(materialShaderId, stateHash)` key.
359
+
360
+ /**
361
+ * Register a material shader entry under a stable identifier.
362
+ *
363
+ * **Throws** on:
364
+ * - duplicate registration of the same identifier (programmer error per
365
+ * AGENTS.md "explicit registration" + Inspector "fail-fast no overwrite"
366
+ * pattern; charter P3 explicit failure on misuse).
367
+ *
368
+ * Successful path returns void — the registry mutates in-place. Use
369
+ * {@link findMaterialArtifact} to read back.
370
+ *
371
+ * Identifier conventions (charter F1 / plan-strategy §8 §2):
372
+ * - `forgeax::<kebab-case>` — engine-shipped reserved prefix; the host
373
+ * registers `forgeax::default-standard-pbr` at boot.
374
+ * - `<package>::<id>` or `<guid>` — user-side custom shaders.
375
+ *
376
+ * @example
377
+ * ```ts
378
+ * const registry = new ShaderRegistry({ device, manifestUrl });
379
+ * registry.installMaterialArtifact('forgeax::default-standard-pbr', {
380
+ * source: composedWgsl,
381
+ * paramSchema: defaultStandardPbrSchema,
382
+ * });
383
+ * ```
384
+ */
385
+ installMaterialArtifact(identifier: string, entry: MaterialShaderEntry): void {
386
+ if (this.#materialShaders.has(identifier)) {
387
+ throw new Error(
388
+ `ShaderRegistry: material shader identifier '${identifier}' already registered; same-name re-register is forbidden (AGENTS.md "explicit registration" + Inspector "fail-fast no overwrite" pattern). Use findMaterialArtifact to read back the existing entry.`,
389
+ );
390
+ }
391
+ // bug-20260619: user shaders registered directly here bypass the build-time
392
+ // superset gate (vite-plugin-shader's WGSL reflection). A schema that omits
393
+ // a texture field the WGSL actually samples would let the extract stage's
394
+ // `validateTextureHandle` silently drop the handle and fall back to the
395
+ // default white texture (opaque-white grass/windows in the LO 4.3 blending
396
+ // demo). Fail fast at register time instead (charter P3 explicit failure).
397
+ // Scoped to user shaders: engine `forgeax::*` shaders go through the
398
+ // build-time gate and may sample engine-injected textures (emissive /
399
+ // occlusion) absent from their schema by design.
400
+ const paramSchemaProjection = this.#paramSchemaProjectionOwner.admit({
401
+ ownerId: identifier,
402
+ revision: 1,
403
+ schema: entry.paramSchema,
404
+ });
405
+ if (!identifier.startsWith(FORGEAX_RESERVED_PATH_PREFIX)) {
406
+ const undeclared = findUndeclaredSampledTextures(entry.source, paramSchemaProjection.schema);
407
+ if (undeclared.length > 0) {
408
+ throw new Error(
409
+ `ShaderRegistry: material shader '${identifier}' samples texture(s) [${undeclared.join(', ')}] in its WGSL but its paramSchema does not declare them as texture entries. Add { name: '${undeclared[0]}', type: 'texture2d' } (and any others listed) to the paramSchema, or the engine would silently bind the default white texture (charter P3 explicit failure; see docs/handover/2026-06-19-blending-transparency-regression-bisect.md).`,
410
+ );
411
+ }
412
+ }
413
+ this.#materialShaders.set(
414
+ identifier,
415
+ Object.freeze({
416
+ source: entry.source,
417
+ paramSchema: paramSchemaProjection.schema,
418
+ paramSchemaProjection,
419
+ }),
420
+ );
421
+ }
422
+
423
+ /**
424
+ * Lookup a previously-registered material shader entry by identifier.
425
+ *
426
+ * Returns `Result.ok(entry)` on hit, `Result.err(material-shader-not-found)`
427
+ * on miss. The `Result` shape mirrors `get(hash)` for consistent
428
+ * abstraction across the runtime registry surface (charter P4).
429
+ *
430
+ * @example
431
+ * ```ts
432
+ * const r = registry.findMaterialArtifact('forgeax::default-standard-pbr');
433
+ * if (!r.ok) return handleError(r.error);
434
+ * const { source, paramSchema } = r.value;
435
+ * ```
436
+ */
437
+ findMaterialArtifact(
438
+ identifier: string,
439
+ ): ShaderResult<RegisteredMaterialShaderEntry, ShaderError> {
440
+ const entry = this.#materialShaders.get(identifier);
441
+ if (entry === undefined) {
442
+ return err(
443
+ materialShaderNotFound({
444
+ identifier,
445
+ expected: Array.from(this.#materialShaders.keys()),
446
+ hint: `register the shader via ShaderRegistry.installMaterialArtifact('${identifier}', ...) at engine boot, or grep '${FORGEAX_RESERVED_PATH_PREFIX}' to enumerate engine-shipped reserved identifiers`,
447
+ }),
448
+ );
449
+ }
450
+ return ok(entry);
451
+ }
452
+
453
+ /**
454
+ * Iterate every registered material shader identifier in registration
455
+ * order (charter F1 grep gate: `for (const id of registry.materialShaderIdentifiers())`
456
+ * lists every `forgeax::*` + user shader without a private-field reach-in).
457
+ */
458
+ materialShaderIdentifiers(): IterableIterator<string> {
459
+ return this.#materialShaders.keys();
460
+ }
461
+
462
+ /** Runtime diagnostic counters for the immutable ParamSchema owner. */
463
+ paramSchemaProjectionStats(): ParamSchemaProjectionOwnerStats {
464
+ return this.#paramSchemaProjectionOwner.stats();
465
+ }
466
+
467
+ /**
468
+ * Iterate every MaterialShaderManifestEntry parsed from manifest.json's
469
+ * `materialShaders[]` field during `loadManifest()`.
470
+ *
471
+ * feat-20260526-pbr-uniform-fallback-no-storage-buffer M3 / w12:
472
+ * createRenderer variant resolution consumes this list to select the
473
+ * correct variant (by `caps.storageBuffer`) and register the resolved
474
+ * WGSL via `installMaterialArtifact`.
475
+ *
476
+ * Returns an empty iterator when no manifest has been loaded yet or
477
+ * the manifest lacks `materialShaders` (backward-compatible).
478
+ */
479
+ materialShaderManifestEntries(): IterableIterator<MaterialShaderManifestEntry> {
480
+ return this.#materialShaderManifestEntries.values();
481
+ }
482
+ }
483
+
484
+ // ─── helpers ────────────────────────────────────────────────────────────────────
485
+
486
+ function isValidManifestEntry(value: unknown): value is ManifestEntry {
487
+ if (typeof value !== 'object' || value === null) return false;
488
+ const v = value as Record<string, unknown>;
489
+ if (typeof v.hash !== 'string') return false;
490
+ if (typeof v.wgsl !== 'string') return false;
491
+ // glsl allows string | undefined | null (manifest.json may serialize null as
492
+ // 'glsl: null').
493
+ if (v.glsl !== undefined && v.glsl !== null && typeof v.glsl !== 'string') return false;
494
+ if (typeof v.bindings !== 'string') return false;
495
+ return true;
496
+ }
497
+
498
+ function isValidMaterialShaderManifestEntry(value: unknown): value is MaterialShaderManifestEntry {
499
+ if (typeof value !== 'object' || value === null) return false;
500
+ const v = value as Record<string, unknown>;
501
+ if (typeof v.identifier !== 'string') return false;
502
+ if (typeof v.sourcePath !== 'string') return false;
503
+ if (typeof v.composedWgsl !== 'string') return false;
504
+ if (typeof v.paramSchema !== 'string') return false;
505
+ if (!Array.isArray(v.variants)) return false;
506
+ if (!v.variants.every(isValidMaterialShaderManifestVariant)) return false;
507
+ return v.uvSetCount === undefined || typeof v.uvSetCount === 'number';
508
+ }
509
+
510
+ function isValidMaterialShaderManifestVariant(value: unknown): boolean {
511
+ if (typeof value !== 'object' || value === null) return false;
512
+ const v = value as Record<string, unknown>;
513
+ if (typeof v.definesKey !== 'string') return false;
514
+ if (typeof v.composedWgsl !== 'string') return false;
515
+ if (typeof v.defines !== 'object' || v.defines === null || Array.isArray(v.defines)) {
516
+ return false;
517
+ }
518
+ return Object.values(v.defines).every((define) => typeof define === 'boolean');
519
+ }
@@ -0,0 +1,33 @@
1
+ import { beforeAll, describe, expect, it } from 'vitest';
2
+
3
+ let source = '';
4
+
5
+ beforeAll(async () => {
6
+ const fs = await import(/* @vite-ignore */ 'node:fs');
7
+ const path = await import(/* @vite-ignore */ 'node:path');
8
+ const url = await import(/* @vite-ignore */ 'node:url');
9
+ source = fs.readFileSync(
10
+ path.resolve(
11
+ path.dirname(url.fileURLToPath(import.meta.url)),
12
+ '..',
13
+ 'atmosphere-background.wgsl',
14
+ ),
15
+ 'utf8',
16
+ );
17
+ });
18
+
19
+ describe('analytic atmosphere background ray and disc', () => {
20
+ it('reconstructs a camera-relative world ray before comparing the Sun direction', () => {
21
+ expect(source).toMatch(/world\.xyz\s*\/\s*world\.w\s*-\s*view\.cameraPos/);
22
+ expect(source).toContain('atmosphere_sun_disc_radiance');
23
+ expect(source).toMatch(/dot\(normalize\(viewDirection\),\s*normalize\(sunDirection\)\)/);
24
+ });
25
+
26
+ it('keeps the Sun disc out of the generated cube and IBL path', () => {
27
+ expect(source).toContain('sunDiscEnabled');
28
+ expect(source).toContain('atmosphere.sunDirection.y > 0.0');
29
+ expect(source).toContain('solidAngle');
30
+ expect(source).toContain('textureSample(sky, skySampler, cubeDirection)');
31
+ expect(source).toContain('vec3<f32>(direction.x, -direction.y, direction.z)');
32
+ });
33
+ });
@@ -0,0 +1,68 @@
1
+ import { beforeAll, describe, expect, it } from 'vitest';
2
+
3
+ type Direction = readonly [number, number, number];
4
+
5
+ let cubeSource = '';
6
+ let iblSource = '';
7
+
8
+ beforeAll(async () => {
9
+ const fs = await import(/* @vite-ignore */ 'node:fs');
10
+ const path = await import(/* @vite-ignore */ 'node:path');
11
+ const url = await import(/* @vite-ignore */ 'node:url');
12
+ const shaderRoot = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..');
13
+ cubeSource = fs.readFileSync(path.resolve(shaderRoot, 'atmosphere-cubemap.wgsl'), 'utf8');
14
+ iblSource = fs.readFileSync(path.resolve(shaderRoot, 'atmosphere-ibl.wgsl'), 'utf8');
15
+ });
16
+
17
+ function worldDirection(face: number, x: number, y: number): Direction {
18
+ switch (face) {
19
+ case 0:
20
+ return [1, -y, -x];
21
+ case 1:
22
+ return [-1, -y, x];
23
+ case 2:
24
+ return [x, -1, -y];
25
+ case 3:
26
+ return [x, 1, y];
27
+ case 4:
28
+ return [x, -y, 1];
29
+ default:
30
+ return [-x, -y, -1];
31
+ }
32
+ }
33
+
34
+ function sameDirection(left: Direction, right: Direction): boolean {
35
+ return left.every((value, index) => Math.abs(value - (right[index] ?? 0)) < 1e-8);
36
+ }
37
+
38
+ describe('analytic cubemap world-direction convention', () => {
39
+ it('keeps every sampled face edge continuous with another face', () => {
40
+ const samples = [-1, -0.5, 0, 0.5, 1];
41
+ const edges: { readonly face: number; readonly direction: Direction }[] = [];
42
+ for (let face = 0; face < 6; face += 1) {
43
+ for (const sample of samples) {
44
+ edges.push({ face, direction: worldDirection(face, -1, sample) });
45
+ edges.push({ face, direction: worldDirection(face, 1, sample) });
46
+ edges.push({ face, direction: worldDirection(face, sample, -1) });
47
+ edges.push({ face, direction: worldDirection(face, sample, 1) });
48
+ }
49
+ }
50
+ for (const edge of edges) {
51
+ expect(
52
+ edges.some(
53
+ (candidate) =>
54
+ candidate.face !== edge.face && sameDirection(candidate.direction, edge.direction),
55
+ ),
56
+ ).toBe(true);
57
+ }
58
+ });
59
+
60
+ it('pins the corrected Y faces and applies the same convention to IBL', () => {
61
+ for (const source of [cubeSource, iblSource]) {
62
+ expect(source).toContain('case 2u: { output.direction = vec3<f32>(x, -1.0, -y); }');
63
+ expect(source).toContain('case 3u: { output.direction = vec3<f32>(x, 1.0, y); }');
64
+ }
65
+ expect(iblSource).toContain('-sampleDirection.y');
66
+ expect(iblSource).toContain('-lightDirection.y');
67
+ });
68
+ });