@forgeax/engine-vfx 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 (108) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +214 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/authoring-descriptor.unit.test.d.ts +2 -0
  5. package/dist/__tests__/authoring-descriptor.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/channel-input.unit.test.d.ts +2 -0
  7. package/dist/__tests__/channel-input.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/channel-inspect.unit.test.d.ts +2 -0
  9. package/dist/__tests__/channel-inspect.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/code-source-v2.unit.test.d.ts +2 -0
  11. package/dist/__tests__/code-source-v2.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/data-interface-contract.unit.test.d.ts +2 -0
  13. package/dist/__tests__/data-interface-contract.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/effect-contract.unit.test.d.ts +2 -0
  15. package/dist/__tests__/effect-contract.unit.test.d.ts.map +1 -0
  16. package/dist/__tests__/gpu-program-simulation-culling-owner.test-d.d.ts +2 -0
  17. package/dist/__tests__/gpu-program-simulation-culling-owner.test-d.d.ts.map +1 -0
  18. package/dist/__tests__/gpu-reflection-vocabulary-owner.test-d.d.ts +2 -0
  19. package/dist/__tests__/gpu-reflection-vocabulary-owner.test-d.d.ts.map +1 -0
  20. package/dist/__tests__/gpu-runtime.integration.test.d.ts +2 -0
  21. package/dist/__tests__/gpu-runtime.integration.test.d.ts.map +1 -0
  22. package/dist/__tests__/inspect-snapshot.unit.test.d.ts +2 -0
  23. package/dist/__tests__/inspect-snapshot.unit.test.d.ts.map +1 -0
  24. package/dist/__tests__/instance-public-api.unit.test.d.ts +2 -0
  25. package/dist/__tests__/instance-public-api.unit.test.d.ts.map +1 -0
  26. package/dist/__tests__/instance.unit.test.d.ts +2 -0
  27. package/dist/__tests__/instance.unit.test.d.ts.map +1 -0
  28. package/dist/__tests__/loader-v1-rejection.unit.test.d.ts +2 -0
  29. package/dist/__tests__/loader-v1-rejection.unit.test.d.ts.map +1 -0
  30. package/dist/__tests__/player-reflection.unit.test.d.ts +2 -0
  31. package/dist/__tests__/player-reflection.unit.test.d.ts.map +1 -0
  32. package/dist/__tests__/player-type.test-d.d.ts +2 -0
  33. package/dist/__tests__/player-type.test-d.d.ts.map +1 -0
  34. package/dist/__tests__/renderer-source.unit.test.d.ts +2 -0
  35. package/dist/__tests__/renderer-source.unit.test.d.ts.map +1 -0
  36. package/dist/__tests__/replay-canonical.unit.test.d.ts +2 -0
  37. package/dist/__tests__/replay-canonical.unit.test.d.ts.map +1 -0
  38. package/dist/__tests__/runtime-dist-isolation.test.d.ts +2 -0
  39. package/dist/__tests__/runtime-dist-isolation.test.d.ts.map +1 -0
  40. package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts +2 -0
  41. package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts.map +1 -0
  42. package/dist/__tests__/stage-source.unit.test.d.ts +2 -0
  43. package/dist/__tests__/stage-source.unit.test.d.ts.map +1 -0
  44. package/dist/__tests__/vfx-data-interface-vocabulary-owner.test-d.d.ts +2 -0
  45. package/dist/__tests__/vfx-data-interface-vocabulary-owner.test-d.d.ts.map +1 -0
  46. package/dist/__tests__/vfx-performance-contract.unit.test.d.ts +2 -0
  47. package/dist/__tests__/vfx-performance-contract.unit.test.d.ts.map +1 -0
  48. package/dist/__tests__/vfx-value-type-owner.test-d.d.ts +2 -0
  49. package/dist/__tests__/vfx-value-type-owner.test-d.d.ts.map +1 -0
  50. package/dist/assets/particle-effect-decoder.d.ts +3 -0
  51. package/dist/assets/particle-effect-decoder.d.ts.map +1 -0
  52. package/dist/authoring-descriptor.d.ts +60 -0
  53. package/dist/authoring-descriptor.d.ts.map +1 -0
  54. package/dist/code-source.d.ts +140 -0
  55. package/dist/code-source.d.ts.map +1 -0
  56. package/dist/data-interface.d.ts +53 -0
  57. package/dist/data-interface.d.ts.map +1 -0
  58. package/dist/effect-contract.d.ts +57 -0
  59. package/dist/effect-contract.d.ts.map +1 -0
  60. package/dist/gpu-loader.d.ts +33 -0
  61. package/dist/gpu-loader.d.ts.map +1 -0
  62. package/dist/gpu-program.d.ts +80 -0
  63. package/dist/gpu-program.d.ts.map +1 -0
  64. package/dist/gpu-runtime.d.ts +174 -0
  65. package/dist/gpu-runtime.d.ts.map +1 -0
  66. package/dist/index.d.ts +21 -0
  67. package/dist/index.d.ts.map +1 -0
  68. package/dist/index.mjs +1986 -0
  69. package/dist/index.mjs.map +1 -0
  70. package/dist/instance.d.ts +86 -0
  71. package/dist/instance.d.ts.map +1 -0
  72. package/dist/player.d.ts +17 -0
  73. package/dist/player.d.ts.map +1 -0
  74. package/package.json +66 -0
  75. package/src/__tests__/authoring-descriptor.unit.test.ts +138 -0
  76. package/src/__tests__/channel-input.unit.test.ts +65 -0
  77. package/src/__tests__/channel-inspect.unit.test.ts +65 -0
  78. package/src/__tests__/code-source-v2.unit.test.ts +121 -0
  79. package/src/__tests__/data-interface-contract.unit.test.ts +92 -0
  80. package/src/__tests__/effect-contract.unit.test.ts +62 -0
  81. package/src/__tests__/gpu-program-simulation-culling-owner.test-d.ts +58 -0
  82. package/src/__tests__/gpu-reflection-vocabulary-owner.test-d.ts +23 -0
  83. package/src/__tests__/gpu-runtime.integration.test.ts +549 -0
  84. package/src/__tests__/inspect-snapshot.unit.test.ts +51 -0
  85. package/src/__tests__/instance-public-api.unit.test.ts +27 -0
  86. package/src/__tests__/instance.unit.test.ts +107 -0
  87. package/src/__tests__/loader-v1-rejection.unit.test.ts +125 -0
  88. package/src/__tests__/player-reflection.unit.test.ts +91 -0
  89. package/src/__tests__/player-type.test-d.ts +54 -0
  90. package/src/__tests__/renderer-source.unit.test.ts +58 -0
  91. package/src/__tests__/replay-canonical.unit.test.ts +68 -0
  92. package/src/__tests__/runtime-dist-isolation.test.ts +100 -0
  93. package/src/__tests__/scriptable-pack-consumer.test-d.ts +9 -0
  94. package/src/__tests__/stage-source.unit.test.ts +64 -0
  95. package/src/__tests__/vfx-data-interface-vocabulary-owner.test-d.ts +34 -0
  96. package/src/__tests__/vfx-performance-contract.unit.test.ts +19 -0
  97. package/src/__tests__/vfx-value-type-owner.test-d.ts +32 -0
  98. package/src/assets/particle-effect-decoder.ts +34 -0
  99. package/src/authoring-descriptor.ts +355 -0
  100. package/src/code-source.ts +827 -0
  101. package/src/data-interface.ts +181 -0
  102. package/src/effect-contract.ts +220 -0
  103. package/src/gpu-loader.ts +279 -0
  104. package/src/gpu-program.ts +101 -0
  105. package/src/gpu-runtime.ts +807 -0
  106. package/src/index.ts +110 -0
  107. package/src/instance.ts +379 -0
  108. package/src/player.ts +21 -0
@@ -0,0 +1,181 @@
1
+ import { err, ok, type Result } from '@forgeax/engine-types';
2
+
3
+ export type VfxDataInterfaceKind = keyof VfxDataInterfaceBindingTypeByKind;
4
+
5
+ export type VfxDataInterfaceToken = `vfx:${VfxDataInterfaceKind}`;
6
+
7
+ export type VfxDataInterfaceBindingType = VfxDataInterfaceBindingTypeByKind[VfxDataInterfaceKind];
8
+
9
+ type VfxDataInterfaceBindingTypeByKind = {
10
+ readonly camera: 'uniform';
11
+ readonly 'scene-depth': 'sampled-depth';
12
+ readonly noise: 'sampled-float';
13
+ readonly channel: 'storage-read';
14
+ };
15
+
16
+ export type VfxDataInterfaceLifetime = 'generation';
17
+
18
+ export interface VfxDataInterfaceRequirement {
19
+ readonly token: VfxDataInterfaceToken;
20
+ readonly kind: VfxDataInterfaceKind;
21
+ readonly binding: number;
22
+ readonly bindingType: VfxDataInterfaceBindingType;
23
+ readonly lifetime: VfxDataInterfaceLifetime;
24
+ }
25
+
26
+ export interface VfxDataInterfaceResource {
27
+ readonly token: VfxDataInterfaceToken;
28
+ readonly kind: VfxDataInterfaceKind;
29
+ readonly bindingType: VfxDataInterfaceBindingType;
30
+ readonly generation: number;
31
+ }
32
+
33
+ export interface VfxDataInterfaceErrorDetail {
34
+ readonly token: VfxDataInterfaceToken;
35
+ readonly providerId?: string;
36
+ readonly expectedGeneration?: number;
37
+ readonly actualGeneration?: number;
38
+ readonly expectedBindingType?: VfxDataInterfaceBindingType;
39
+ readonly actualBindingType?: VfxDataInterfaceBindingType;
40
+ }
41
+
42
+ export interface VfxDataInterfaceError {
43
+ readonly code:
44
+ | 'vfx-data-interface-missing'
45
+ | 'vfx-data-interface-wrong-type'
46
+ | 'vfx-data-interface-stale'
47
+ | 'vfx-data-interface-duplicate';
48
+ readonly expected: string;
49
+ readonly hint: string;
50
+ readonly detail: VfxDataInterfaceErrorDetail;
51
+ }
52
+
53
+ export type VfxDataInterfaceProvider<K extends VfxDataInterfaceKind = VfxDataInterfaceKind> = {
54
+ readonly id: string;
55
+ readonly token: `vfx:${K}`;
56
+ readonly kind: K;
57
+ readonly bindingType: VfxDataInterfaceBindingTypeByKind[K];
58
+ readonly provide: (generation: number) => Result<VfxDataInterfaceResource, VfxDataInterfaceError>;
59
+ };
60
+
61
+ export interface VfxDataInterfaceResolution {
62
+ readonly generation: number;
63
+ readonly readiness: 'ready';
64
+ readonly resources: readonly VfxDataInterfaceResource[];
65
+ }
66
+
67
+ function failure(
68
+ code: VfxDataInterfaceError['code'],
69
+ requirement: VfxDataInterfaceRequirement,
70
+ expected: string,
71
+ hint: string,
72
+ detail: Omit<VfxDataInterfaceErrorDetail, 'token'> = {},
73
+ ): VfxDataInterfaceError {
74
+ return { code, expected, hint, detail: { token: requirement.token, ...detail } };
75
+ }
76
+
77
+ export function resolveVfxDataInterfaces(
78
+ requirements: readonly VfxDataInterfaceRequirement[],
79
+ providers: readonly VfxDataInterfaceProvider[],
80
+ generation: number,
81
+ ): Result<VfxDataInterfaceResolution, VfxDataInterfaceError> {
82
+ const byToken = new Map<VfxDataInterfaceToken, VfxDataInterfaceProvider>();
83
+ for (const provider of providers) {
84
+ const prior = byToken.get(provider.token);
85
+ if (prior !== undefined) {
86
+ const requirement = requirements.find((entry) => entry.token === provider.token) ?? {
87
+ token: provider.token,
88
+ kind: provider.kind,
89
+ binding: -1,
90
+ bindingType: provider.bindingType,
91
+ lifetime: 'generation',
92
+ };
93
+ return err(
94
+ failure(
95
+ 'vfx-data-interface-duplicate',
96
+ requirement,
97
+ 'at most one provider for each reflected Data Interface token',
98
+ `remove duplicate providers ${prior.id} and ${provider.id} and retry`,
99
+ { providerId: provider.id },
100
+ ),
101
+ );
102
+ }
103
+ byToken.set(provider.token, provider);
104
+ }
105
+
106
+ const resources: VfxDataInterfaceResource[] = [];
107
+ for (const requirement of requirements) {
108
+ const provider = byToken.get(requirement.token);
109
+ if (provider === undefined) {
110
+ return err(
111
+ failure(
112
+ 'vfx-data-interface-missing',
113
+ requirement,
114
+ `a registered ${requirement.kind} provider for ${requirement.token}`,
115
+ `register ${requirement.token} for generation ${generation} before starting the effect`,
116
+ ),
117
+ );
118
+ }
119
+ if (provider.kind !== requirement.kind || provider.bindingType !== requirement.bindingType) {
120
+ return err(
121
+ failure(
122
+ 'vfx-data-interface-wrong-type',
123
+ requirement,
124
+ `${requirement.kind} with ${requirement.bindingType} binding semantics`,
125
+ `replace provider ${provider.id} with a ${requirement.token} provider matching reflection`,
126
+ {
127
+ providerId: provider.id,
128
+ expectedBindingType: requirement.bindingType,
129
+ actualBindingType: provider.bindingType,
130
+ },
131
+ ),
132
+ );
133
+ }
134
+ const provided = provider.provide(generation);
135
+ if (!provided.ok) return provided;
136
+ const resource = provided.value;
137
+ if (resource.token !== requirement.token || resource.kind !== requirement.kind) {
138
+ return err(
139
+ failure(
140
+ 'vfx-data-interface-wrong-type',
141
+ requirement,
142
+ `${requirement.kind} resource for ${requirement.token}`,
143
+ `repair provider ${provider.id} so its resource matches its reflected token`,
144
+ { providerId: provider.id },
145
+ ),
146
+ );
147
+ }
148
+ if (resource.bindingType !== requirement.bindingType) {
149
+ return err(
150
+ failure(
151
+ 'vfx-data-interface-wrong-type',
152
+ requirement,
153
+ `resource binding type ${requirement.bindingType}`,
154
+ `repair provider ${provider.id} resource binding metadata`,
155
+ {
156
+ providerId: provider.id,
157
+ expectedBindingType: requirement.bindingType,
158
+ actualBindingType: resource.bindingType,
159
+ },
160
+ ),
161
+ );
162
+ }
163
+ if (resource.generation !== generation) {
164
+ return err(
165
+ failure(
166
+ 'vfx-data-interface-stale',
167
+ requirement,
168
+ `a resource from generation ${generation}`,
169
+ `refresh provider ${provider.id} for generation ${generation} before rendering`,
170
+ {
171
+ providerId: provider.id,
172
+ expectedGeneration: generation,
173
+ actualGeneration: resource.generation,
174
+ },
175
+ ),
176
+ );
177
+ }
178
+ resources.push(resource);
179
+ }
180
+ return ok({ generation, readiness: 'ready', resources: Object.freeze(resources) });
181
+ }
@@ -0,0 +1,220 @@
1
+ import { err, ok, type Result } from '@forgeax/engine-types';
2
+ import type { VfxDataInterfaceRequirement } from './data-interface.js';
3
+
4
+ export type VfxValue = number | readonly number[];
5
+ export type VfxValueMap = Readonly<Record<string, VfxValue>>;
6
+
7
+ export interface VfxReflectedField {
8
+ readonly name: string;
9
+ readonly type: VfxValueType;
10
+ readonly offset: number;
11
+ readonly size: number;
12
+ readonly alignment: number;
13
+ readonly defaultValue?: VfxValue;
14
+ }
15
+
16
+ export interface VfxReflectedStruct {
17
+ readonly name: string;
18
+ readonly fields: readonly VfxReflectedField[];
19
+ readonly size: number;
20
+ readonly alignment: number;
21
+ }
22
+
23
+ export interface VfxEffectReflection {
24
+ readonly version: 1;
25
+ readonly parameters: VfxReflectedStruct;
26
+ readonly custom: VfxReflectedStruct;
27
+ readonly dataInterfaces?: readonly VfxDataInterfaceRequirement[];
28
+ readonly fingerprint: string;
29
+ }
30
+
31
+ export interface VfxEffectContractErrorDetail {
32
+ readonly path: string;
33
+ readonly actual?: unknown;
34
+ }
35
+
36
+ export interface VfxEffectContractError {
37
+ readonly code:
38
+ | 'vfx-value-unknown-field'
39
+ | 'vfx-value-type-mismatch'
40
+ | 'vfx-value-not-finite'
41
+ | 'vfx-reflection-invalid';
42
+ readonly expected: string;
43
+ readonly hint: string;
44
+ readonly detail: VfxEffectContractErrorDetail;
45
+ }
46
+
47
+ export interface VfxEffectContract<Values extends VfxValueMap = VfxValueMap> {
48
+ readonly reflection: VfxEffectReflection;
49
+ readonly fingerprint: string;
50
+ readonly packedSize: number;
51
+ readonly defaults: Values;
52
+ createValues(initial?: Partial<Values>): Result<Values, VfxEffectContractError>;
53
+ validateValues(values: VfxValueMap): Result<Values, VfxEffectContractError>;
54
+ pack(values: Values): Result<Uint8Array, VfxEffectContractError>;
55
+ }
56
+
57
+ const VECTOR_LENGTH = {
58
+ f32: 1,
59
+ i32: 1,
60
+ u32: 1,
61
+ 'vec2<f32>': 2,
62
+ 'vec3<f32>': 3,
63
+ 'vec4<f32>': 4,
64
+ };
65
+
66
+ export type VfxValueType = keyof typeof VECTOR_LENGTH;
67
+
68
+ function fieldList(reflection: VfxEffectReflection): readonly VfxReflectedField[] {
69
+ return [...reflection.parameters.fields, ...reflection.custom.fields];
70
+ }
71
+
72
+ function zeroValue(type: VfxValueType): VfxValue {
73
+ const length = VECTOR_LENGTH[type];
74
+ return length === 1 ? 0 : Array.from({ length }, () => 0);
75
+ }
76
+
77
+ function valueMatches(type: VfxValueType, value: unknown): boolean {
78
+ const length = VECTOR_LENGTH[type];
79
+ if (length === 1) return typeof value === 'number' && Number.isFinite(value);
80
+ return (
81
+ Array.isArray(value) &&
82
+ value.length === length &&
83
+ value.every((component) => typeof component === 'number' && Number.isFinite(component))
84
+ );
85
+ }
86
+
87
+ function fail(
88
+ code: VfxEffectContractError['code'],
89
+ path: string,
90
+ expected: string,
91
+ hint: string,
92
+ actual?: unknown,
93
+ ): Result<never, VfxEffectContractError> {
94
+ return err({
95
+ code,
96
+ expected,
97
+ hint,
98
+ detail: actual === undefined ? { path } : { path, actual },
99
+ });
100
+ }
101
+
102
+ function validateReflection(reflection: VfxEffectReflection): Result<true, VfxEffectContractError> {
103
+ if (
104
+ reflection.version !== 1 ||
105
+ typeof reflection.fingerprint !== 'string' ||
106
+ !reflection.fingerprint.startsWith('sha256:')
107
+ ) {
108
+ return fail(
109
+ 'vfx-reflection-invalid',
110
+ 'reflection',
111
+ 'a version 1 reflection with a sha256 fingerprint',
112
+ 'recook the effect with the current VFX compiler',
113
+ );
114
+ }
115
+ const names = new Set<string>();
116
+ for (const field of fieldList(reflection)) {
117
+ if (names.has(field.name)) {
118
+ return fail(
119
+ 'vfx-reflection-invalid',
120
+ field.name,
121
+ 'unique field names across parameters and custom data',
122
+ 'rename the duplicate WGSL field and recook',
123
+ );
124
+ }
125
+ names.add(field.name);
126
+ }
127
+ return ok(true);
128
+ }
129
+
130
+ function defaultsFor<Values extends VfxValueMap>(reflection: VfxEffectReflection): Values {
131
+ const defaults: Record<string, VfxValue> = {};
132
+ for (const field of fieldList(reflection)) {
133
+ defaults[field.name] =
134
+ field.defaultValue === undefined ? zeroValue(field.type) : field.defaultValue;
135
+ }
136
+ return Object.freeze(defaults) as Values;
137
+ }
138
+
139
+ function validateMap<Values extends VfxValueMap>(
140
+ reflection: VfxEffectReflection,
141
+ values: VfxValueMap,
142
+ ): Result<Values, VfxEffectContractError> {
143
+ const fields = new Map(fieldList(reflection).map((field) => [field.name, field]));
144
+ for (const name of Object.keys(values)) {
145
+ const field = fields.get(name);
146
+ if (field === undefined) {
147
+ return fail(
148
+ 'vfx-value-unknown-field',
149
+ name,
150
+ 'a field declared by VfxParameters or VfxCustom',
151
+ `remove ${name} or declare it in the authored WGSL struct`,
152
+ values[name],
153
+ );
154
+ }
155
+ if (!valueMatches(field.type, values[name])) {
156
+ return fail(
157
+ 'vfx-value-type-mismatch',
158
+ name,
159
+ field.type,
160
+ `provide ${name} as ${field.type}`,
161
+ values[name],
162
+ );
163
+ }
164
+ }
165
+ return ok(Object.freeze({ ...values }) as Values);
166
+ }
167
+
168
+ function writeValue(view: DataView, offset: number, type: VfxValueType, value: VfxValue): void {
169
+ const values = typeof value === 'number' ? [value] : value;
170
+ for (let index = 0; index < values.length; index += 1) {
171
+ const component = values[index] ?? 0;
172
+ if (type === 'i32') view.setInt32(offset + index * 4, component, true);
173
+ else if (type === 'u32') view.setUint32(offset + index * 4, component, true);
174
+ else view.setFloat32(offset + index * 4, component, true);
175
+ }
176
+ }
177
+
178
+ export function createVfxEffectContract<Values extends VfxValueMap = VfxValueMap>(
179
+ reflection: VfxEffectReflection,
180
+ ): VfxEffectContract<Values> {
181
+ const checked = validateReflection(reflection);
182
+ if (!checked.ok) throw new TypeError(checked.error.hint);
183
+ const defaults = defaultsFor<Values>(reflection);
184
+ const fields = fieldList(reflection);
185
+ const customBase = reflection.parameters.size;
186
+ const packedSize = customBase + reflection.custom.size;
187
+ return {
188
+ reflection,
189
+ fingerprint: reflection.fingerprint,
190
+ packedSize,
191
+ defaults,
192
+ createValues(initial = {} as Partial<Values>) {
193
+ const merged = { ...defaults, ...initial };
194
+ return validateMap<Values>(reflection, merged);
195
+ },
196
+ validateValues(values) {
197
+ return validateMap<Values>(reflection, values);
198
+ },
199
+ pack(values) {
200
+ const checkedValues = validateMap<Values>(reflection, values);
201
+ if (!checkedValues.ok) return checkedValues;
202
+ const bytes = new Uint8Array(packedSize);
203
+ const view = new DataView(bytes.buffer);
204
+ for (const field of fields) {
205
+ const value = checkedValues.value[field.name];
206
+ if (value === undefined) continue;
207
+ const base = reflection.parameters.fields.includes(field) ? 0 : customBase;
208
+ writeValue(view, base + field.offset, field.type, value);
209
+ }
210
+ return ok(bytes);
211
+ },
212
+ };
213
+ }
214
+
215
+ export function validateVfxEffectValues(
216
+ reflection: VfxEffectReflection,
217
+ values: VfxValueMap,
218
+ ): Result<VfxValueMap, VfxEffectContractError> {
219
+ return validateMap(reflection, values);
220
+ }
@@ -0,0 +1,279 @@
1
+ import type { AssetRegistry } from '@forgeax/engine-assets-runtime';
2
+ import type {
3
+ AssetDecoderContribution,
4
+ AssetKind,
5
+ AssetLoadError,
6
+ LoadContext,
7
+ Result,
8
+ } from '@forgeax/engine-types';
9
+ import { err, ok } from '@forgeax/engine-types';
10
+ import {
11
+ VFX_GPU_PROGRAM_ARTIFACT_KEY,
12
+ VFX_GPU_PROGRAM_FORMAT,
13
+ type VfxGpuEffectAsset,
14
+ type VfxGpuEmitterProgram,
15
+ } from './gpu-program.js';
16
+
17
+ export interface VfxGpuAssetError {
18
+ readonly code:
19
+ | 'vfx-asset-v2-invalid'
20
+ | 'vfx-asset-v2-program-missing'
21
+ | 'vfx-asset-v2-fingerprint-mismatch';
22
+ readonly expected: string;
23
+ readonly hint: string;
24
+ readonly detail: { readonly guid: string; readonly path: string };
25
+ }
26
+
27
+ interface PackLoaderInput {
28
+ readonly guid: string;
29
+ readonly kind: string;
30
+ readonly payload: Record<string, unknown>;
31
+ readonly artifacts: Readonly<
32
+ Record<
33
+ string,
34
+ {
35
+ readonly descriptor: { readonly path: string; readonly mediaType: string };
36
+ readonly bytes: Uint8Array;
37
+ }
38
+ >
39
+ >;
40
+ }
41
+
42
+ function failure(
43
+ code: VfxGpuAssetError['code'],
44
+ guid: string,
45
+ path: string,
46
+ expected: string,
47
+ hint: string,
48
+ ): Result<never, VfxGpuAssetError> {
49
+ return err({ code, expected, hint, detail: { guid, path } });
50
+ }
51
+
52
+ function record(value: unknown): value is Record<string, unknown> {
53
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
54
+ }
55
+
56
+ function hex(bytes: ArrayBuffer): string {
57
+ return [...new Uint8Array(bytes)].map((value) => value.toString(16).padStart(2, '0')).join('');
58
+ }
59
+
60
+ async function fingerprint(bytes: Uint8Array): Promise<string> {
61
+ const source = bytes.buffer.slice(
62
+ bytes.byteOffset,
63
+ bytes.byteOffset + bytes.byteLength,
64
+ ) as ArrayBuffer;
65
+ return `sha256:${hex(await globalThis.crypto.subtle.digest('SHA-256', source))}`;
66
+ }
67
+
68
+ function validReflectionLayout(value: unknown): boolean {
69
+ if (value === undefined) return true;
70
+ if (!record(value) || value.version !== 1) return false;
71
+ if (!record(value.parameters) || !Array.isArray(value.parameters.fields)) return false;
72
+ if (!record(value.custom) || !Array.isArray(value.custom.fields)) return false;
73
+ return typeof value.fingerprint === 'string' && value.fingerprint.startsWith('sha256:');
74
+ }
75
+
76
+ function stableLayouts(emitters: readonly VfxGpuEmitterProgram[]): boolean {
77
+ const fingerprints = emitters
78
+ .map((emitter) => emitter.reflection.layout?.fingerprint)
79
+ .filter((fingerprint): fingerprint is string => fingerprint !== undefined);
80
+ return fingerprints.every((fingerprint) => fingerprint === fingerprints[0]);
81
+ }
82
+
83
+ function validEmitter(value: unknown): value is VfxGpuEmitterProgram {
84
+ const reflection = record(value) && record(value.reflection) ? value.reflection : undefined;
85
+ const layout = reflection !== undefined ? reflection.layout : undefined;
86
+ return (
87
+ record(value) &&
88
+ typeof value.id === 'string' &&
89
+ typeof value.module === 'string' &&
90
+ value.module.length > 0 &&
91
+ Number.isInteger(value.capacity) &&
92
+ (value.capacity as number) > 0 &&
93
+ record(value.backend) &&
94
+ value.backend.required === 'gpu' &&
95
+ Object.keys(value.backend).length === 1 &&
96
+ typeof value.wgsl === 'string' &&
97
+ value.wgsl.length > 0 &&
98
+ record(value.reflection) &&
99
+ validReflectionLayout(layout) &&
100
+ Array.isArray(value.reflection.entryPoints) &&
101
+ value.reflection.entryPoints.includes('forgeax_vfx_spawn_main') &&
102
+ value.reflection.entryPoints.includes('forgeax_vfx_compact_main') &&
103
+ value.reflection.entryPoints.includes('forgeax_vfx_billboard_main') &&
104
+ value.reflection.entryPoints.includes('forgeax_vfx_mesh_main') &&
105
+ value.reflection.entryPoints.includes('forgeax_vfx_ribbon_main') &&
106
+ value.reflection.entryPoints.includes('forgeax_vfx_trail_main') &&
107
+ value.reflection.entryPoints.includes('forgeax_vfx_beam_main')
108
+ );
109
+ }
110
+
111
+ export const vfxGpuEffectPackLoader = {
112
+ kind: 'particle-effect',
113
+ async load(
114
+ input: PackLoaderInput,
115
+ _context: LoadContext,
116
+ ): Promise<Result<VfxGpuEffectAsset, VfxGpuAssetError>> {
117
+ if (
118
+ input.payload.kind !== 'particle-effect' ||
119
+ input.payload.schemaVersion !== 2 ||
120
+ !Array.isArray(input.payload.emitters) ||
121
+ typeof input.payload.programFingerprint !== 'string' ||
122
+ !record(input.payload.program)
123
+ ) {
124
+ return failure(
125
+ 'vfx-asset-v2-invalid',
126
+ input.guid,
127
+ 'payload',
128
+ 'a schemaVersion 2 particle payload with its complete program and fingerprint',
129
+ 'migrate behavior to WGSL and recook; the runtime does not interpret v1',
130
+ );
131
+ }
132
+ const artifact = input.artifacts[VFX_GPU_PROGRAM_ARTIFACT_KEY];
133
+ if (artifact === undefined) {
134
+ return failure(
135
+ 'vfx-asset-v2-program-missing',
136
+ input.guid,
137
+ VFX_GPU_PROGRAM_ARTIFACT_KEY,
138
+ 'the asset-local cooked VFX program',
139
+ 'recook the particle effect and retry the load',
140
+ );
141
+ }
142
+ let decoded: unknown;
143
+ try {
144
+ decoded = JSON.parse(new TextDecoder().decode(artifact.bytes));
145
+ } catch {
146
+ return failure(
147
+ 'vfx-asset-v2-invalid',
148
+ input.guid,
149
+ VFX_GPU_PROGRAM_ARTIFACT_KEY,
150
+ 'canonical JSON program bytes',
151
+ 'recook the particle effect and retry the load',
152
+ );
153
+ }
154
+ if (
155
+ !record(decoded) ||
156
+ decoded.format !== VFX_GPU_PROGRAM_FORMAT ||
157
+ !Array.isArray(decoded.emitters) ||
158
+ !decoded.emitters.every(validEmitter) ||
159
+ !stableLayouts(decoded.emitters as VfxGpuEmitterProgram[])
160
+ ) {
161
+ return failure(
162
+ 'vfx-asset-v2-invalid',
163
+ input.guid,
164
+ VFX_GPU_PROGRAM_ARTIFACT_KEY,
165
+ `a ${VFX_GPU_PROGRAM_FORMAT} managed GPU program`,
166
+ 'recook with the current VFX compiler ABI',
167
+ );
168
+ }
169
+ const decodedEmitters = decoded.emitters as VfxGpuEmitterProgram[];
170
+ if (
171
+ input.payload.program.format !== VFX_GPU_PROGRAM_FORMAT ||
172
+ input.payload.program.fingerprint !== input.payload.programFingerprint ||
173
+ !Array.isArray(input.payload.program.emitters)
174
+ ) {
175
+ return failure(
176
+ 'vfx-asset-v2-invalid',
177
+ input.guid,
178
+ 'payload.program',
179
+ 'the complete canonical program matching payload.programFingerprint',
180
+ 'recook the particle effect atomically',
181
+ );
182
+ }
183
+ const actualFingerprint = await fingerprint(artifact.bytes);
184
+ if (actualFingerprint !== input.payload.programFingerprint) {
185
+ return failure(
186
+ 'vfx-asset-v2-fingerprint-mismatch',
187
+ input.guid,
188
+ 'payload.programFingerprint',
189
+ 'payload and program artifact fingerprints to match',
190
+ 'cold-cook the particle asset so payload and artifact publish atomically',
191
+ );
192
+ }
193
+ const emitters = input.payload.emitters.map((value) => {
194
+ if (!record(value) || typeof value.id !== 'string' || !Number.isInteger(value.capacity)) {
195
+ return undefined;
196
+ }
197
+ return { id: value.id, capacity: value.capacity as number };
198
+ });
199
+ if (
200
+ emitters.some((value) => value === undefined) ||
201
+ emitters.length !== decodedEmitters.length ||
202
+ emitters.some(
203
+ (value, index) =>
204
+ value?.id !== decodedEmitters[index]?.id ||
205
+ value?.capacity !== decodedEmitters[index]?.capacity,
206
+ )
207
+ ) {
208
+ return failure(
209
+ 'vfx-asset-v2-invalid',
210
+ input.guid,
211
+ 'payload.emitters',
212
+ 'payload emitter identities and capacities to match the program',
213
+ 'recook the particle effect atomically',
214
+ );
215
+ }
216
+ return ok(
217
+ Object.freeze({
218
+ guid: input.guid,
219
+ kind: 'particle-effect',
220
+ schemaVersion: 2,
221
+ programFingerprint: actualFingerprint,
222
+ emitters: Object.freeze(emitters as { id: string; capacity: number }[]),
223
+ program: Object.freeze({
224
+ format: VFX_GPU_PROGRAM_FORMAT,
225
+ fingerprint: actualFingerprint,
226
+ emitters: Object.freeze(decodedEmitters),
227
+ }),
228
+ }),
229
+ );
230
+ },
231
+ };
232
+
233
+ /** VFX owner decoder contribution for the single runtime decoder map. */
234
+ export const vfxGpuEffectContribution: AssetDecoderContribution<
235
+ VfxGpuEffectAsset,
236
+ 'particle-effect'
237
+ > = {
238
+ kind: { kind: 'particle-effect' } as AssetKind<VfxGpuEffectAsset, 'particle-effect'>,
239
+ consumer: 'VfxGpuRuntime',
240
+ decoder: {
241
+ async decode({ envelope, artifacts }) {
242
+ const descriptor = envelope.artifacts[VFX_GPU_PROGRAM_ARTIFACT_KEY];
243
+ const bytes = descriptor === undefined ? undefined : await artifacts.read(descriptor);
244
+ if (bytes !== undefined && !bytes.ok) return bytes;
245
+ const result = await vfxGpuEffectPackLoader.load(
246
+ {
247
+ guid: envelope.guid,
248
+ kind: envelope.kind,
249
+ payload: envelope.payload as unknown as Record<string, unknown>,
250
+ artifacts:
251
+ bytes === undefined || descriptor === undefined
252
+ ? {}
253
+ : {
254
+ [VFX_GPU_PROGRAM_ARTIFACT_KEY]: {
255
+ descriptor: { path: descriptor.path, mediaType: descriptor.mediaType },
256
+ bytes: bytes.value,
257
+ },
258
+ },
259
+ },
260
+ {} as LoadContext,
261
+ );
262
+ if (result.ok) return result;
263
+ const error: AssetLoadError = {
264
+ code: 'asset-package-invalid',
265
+ expected: result.error.expected,
266
+ hint: result.error.hint,
267
+ detail: { guid: envelope.guid, reason: result.error.code },
268
+ };
269
+ return err(error);
270
+ },
271
+ },
272
+ };
273
+
274
+ export async function loadVfxGpuEffect(
275
+ registry: AssetRegistry,
276
+ guid: string,
277
+ ): Promise<Result<VfxGpuEffectAsset, unknown>> {
278
+ return registry.load(guid, vfxGpuEffectContribution.kind);
279
+ }