@forgeax/engine-vfx 0.1.27 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -14
- package/dist/assets/particle-effect-decoder.d.ts.map +1 -1
- package/dist/authoring-descriptor.d.ts +4 -4
- package/dist/authoring-descriptor.d.ts.map +1 -1
- package/dist/code-source-v3.d.ts +101 -0
- package/dist/code-source-v3.d.ts.map +1 -0
- package/dist/code-source.d.ts +12 -11
- package/dist/code-source.d.ts.map +1 -1
- package/dist/data-interface.d.ts +17 -1
- package/dist/data-interface.d.ts.map +1 -1
- package/dist/effect-contract.d.ts +11 -1
- package/dist/effect-contract.d.ts.map +1 -1
- package/dist/gpu-loader.d.ts +5 -5
- package/dist/gpu-loader.d.ts.map +1 -1
- package/dist/gpu-program.d.ts +52 -27
- package/dist/gpu-program.d.ts.map +1 -1
- package/dist/gpu-runtime.d.ts +32 -7
- package/dist/gpu-runtime.d.ts.map +1 -1
- package/dist/index.d.ts +10 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +647 -117
- package/dist/index.mjs.map +1 -1
- package/dist/particle-layout.d.ts +62 -0
- package/dist/particle-layout.d.ts.map +1 -0
- package/package.json +5 -5
- package/src/__tests__/authoring-descriptor.unit.test.ts +18 -8
- package/src/__tests__/channel-input.unit.test.ts +4 -1
- package/src/__tests__/channel-overflow-policy-owner.test-d.ts +2 -2
- package/src/__tests__/code-source-v2.unit.test.ts +15 -14
- package/src/__tests__/data-interface-contract.unit.test.ts +41 -2
- package/src/__tests__/effect-contract.unit.test.ts +6 -3
- package/src/__tests__/gpu-program-simulation-culling-owner.test-d.ts +6 -6
- package/src/__tests__/gpu-reflection-vocabulary-owner.test-d.ts +2 -2
- package/src/__tests__/gpu-runtime.integration.test.ts +244 -11
- package/src/__tests__/instance-public-api.unit.test.ts +4 -1
- package/src/__tests__/instance.unit.test.ts +4 -1
- package/src/__tests__/loader-v1-rejection.unit.test.ts +14 -14
- package/src/__tests__/player-reflection.unit.test.ts +2 -2
- package/src/__tests__/player-type.test-d.ts +4 -1
- package/src/__tests__/renderer-source.unit.test.ts +6 -6
- package/src/__tests__/replay-canonical.unit.test.ts +4 -1
- package/src/__tests__/vfx-data-interface-vocabulary-owner.test-d.ts +3 -8
- package/src/assets/particle-effect-decoder.ts +4 -3
- package/src/authoring-descriptor.ts +26 -28
- package/src/code-source-v3.ts +460 -0
- package/src/code-source.ts +23 -27
- package/src/data-interface.ts +66 -1
- package/src/effect-contract.ts +73 -20
- package/src/gpu-loader.ts +61 -35
- package/src/gpu-program.ts +57 -35
- package/src/gpu-runtime.ts +115 -42
- package/src/index.ts +53 -9
- package/src/particle-layout.ts +214 -0
package/src/code-source.ts
CHANGED
|
@@ -16,7 +16,8 @@ export type ParticleBoundsSource =
|
|
|
16
16
|
};
|
|
17
17
|
|
|
18
18
|
export type ParticleRendererOverflowPolicy = 'drop-newest' | 'drop-oldest';
|
|
19
|
-
|
|
19
|
+
/** Internal structural projection used while validating the v3 source. */
|
|
20
|
+
export type ParticleRendererSortingStructure = 'none' | 'emitter' | 'back-to-front';
|
|
20
21
|
|
|
21
22
|
export interface ParticleTextureSheetSource {
|
|
22
23
|
readonly columns: number;
|
|
@@ -29,7 +30,7 @@ export interface ParticleSoftParticleSource {
|
|
|
29
30
|
readonly fadeDistance: number;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
export type
|
|
33
|
+
export type ParticleRendererSourceStructure =
|
|
33
34
|
| {
|
|
34
35
|
readonly kind: 'billboard';
|
|
35
36
|
readonly material: string;
|
|
@@ -40,7 +41,7 @@ export type ParticleRendererSource =
|
|
|
40
41
|
readonly textureSheet?: ParticleTextureSheetSource;
|
|
41
42
|
readonly pivot?: readonly [number, number];
|
|
42
43
|
readonly softParticle?: ParticleSoftParticleSource;
|
|
43
|
-
readonly sorting?:
|
|
44
|
+
readonly sorting?: ParticleRendererSortingStructure;
|
|
44
45
|
}
|
|
45
46
|
| {
|
|
46
47
|
readonly kind: 'mesh';
|
|
@@ -124,7 +125,7 @@ export interface ParticleEventSource {
|
|
|
124
125
|
readonly recursionDepth: number;
|
|
125
126
|
}
|
|
126
127
|
|
|
127
|
-
export interface
|
|
128
|
+
export interface ParticleEmitterSourceStructure {
|
|
128
129
|
readonly id: string;
|
|
129
130
|
readonly capacity: number;
|
|
130
131
|
readonly backend: { readonly required: 'gpu' };
|
|
@@ -136,18 +137,18 @@ export interface ParticleEmitterSourceV2 {
|
|
|
136
137
|
readonly loopDuration?: number;
|
|
137
138
|
};
|
|
138
139
|
readonly program: { readonly module: string };
|
|
139
|
-
readonly renderers: readonly
|
|
140
|
+
readonly renderers: readonly ParticleRendererSourceStructure[];
|
|
140
141
|
readonly channels?: readonly ParticleChannelSource[];
|
|
141
142
|
readonly events?: readonly ParticleEventSource[];
|
|
142
143
|
readonly simulationWhenCulled?: 'continue' | 'pause' | 'restart-on-visible';
|
|
143
144
|
}
|
|
144
145
|
|
|
145
|
-
export interface
|
|
146
|
-
readonly schemaVersion:
|
|
147
|
-
readonly emitters: readonly
|
|
146
|
+
export interface ParticleEffectRootSourceStructure {
|
|
147
|
+
readonly schemaVersion: 3;
|
|
148
|
+
readonly emitters: readonly ParticleEmitterSourceStructure[];
|
|
148
149
|
}
|
|
149
150
|
|
|
150
|
-
export type
|
|
151
|
+
export type ParticleEffectSourceStructure = ParticleEffectRootSourceStructure;
|
|
151
152
|
|
|
152
153
|
export interface ParticleCodeSourceInvalidDetail {
|
|
153
154
|
readonly path: string;
|
|
@@ -364,9 +365,9 @@ function parseEmitter(
|
|
|
364
365
|
value: unknown,
|
|
365
366
|
index: number,
|
|
366
367
|
ids: Set<string>,
|
|
367
|
-
): Result<
|
|
368
|
+
): Result<ParticleEmitterSourceStructure, ParticleCodeSourceError> {
|
|
368
369
|
const path = `emitters[${index}]`;
|
|
369
|
-
if (!record(value)) return invalid(path, 'a
|
|
370
|
+
if (!record(value)) return invalid(path, 'a Program v3 emitter object');
|
|
370
371
|
const extra = allowed(value, [
|
|
371
372
|
'id',
|
|
372
373
|
'capacity',
|
|
@@ -380,7 +381,7 @@ function parseEmitter(
|
|
|
380
381
|
'events',
|
|
381
382
|
'simulationWhenCulled',
|
|
382
383
|
]);
|
|
383
|
-
if (extra !== undefined) return invalid(`${path}.${extra}`, 'a
|
|
384
|
+
if (extra !== undefined) return invalid(`${path}.${extra}`, 'a Program v3 emitter field');
|
|
384
385
|
if (!text(value.id) || ids.has(value.id)) return invalid(`${path}.id`, 'a unique non-empty id');
|
|
385
386
|
const id = value.id;
|
|
386
387
|
ids.add(id);
|
|
@@ -763,28 +764,29 @@ function parseEmitter(
|
|
|
763
764
|
) {
|
|
764
765
|
return invalid(`${path}.simulationWhenCulled`, 'continue, pause, or restart-on-visible', id);
|
|
765
766
|
}
|
|
766
|
-
return ok(value as unknown as
|
|
767
|
+
return ok(value as unknown as ParticleEmitterSourceStructure);
|
|
767
768
|
}
|
|
768
769
|
|
|
769
|
-
|
|
770
|
+
/** Validate the fields shared by the canonical Program v3 source parser. */
|
|
771
|
+
export function parseParticleEffectSourceStructure(
|
|
770
772
|
value: unknown,
|
|
771
|
-
): Result<
|
|
773
|
+
): Result<ParticleEffectSourceStructure, ParticleCodeSourceError> {
|
|
772
774
|
if (!record(value)) return invalid('$', 'a particle effect source object');
|
|
773
|
-
if (value.schemaVersion !==
|
|
775
|
+
if (value.schemaVersion !== 3) {
|
|
774
776
|
return err({
|
|
775
777
|
code: 'vfx-source-version-unsupported',
|
|
776
|
-
expected: 'ParticleEffectSource schemaVersion
|
|
777
|
-
hint: '
|
|
778
|
+
expected: 'ParticleEffectSource schemaVersion 3',
|
|
779
|
+
hint: 'cold-cook the source with the Program v3 compiler; older versions are not executable',
|
|
778
780
|
detail: { path: 'schemaVersion' },
|
|
779
781
|
});
|
|
780
782
|
}
|
|
781
783
|
const extra = allowed(value, ['schemaVersion', 'emitters']);
|
|
782
784
|
if (extra !== undefined) return invalid(extra, 'a root field: emitters');
|
|
783
785
|
if (!Array.isArray(value.emitters) || value.emitters.length === 0) {
|
|
784
|
-
return invalid('emitters', 'at least one
|
|
786
|
+
return invalid('emitters', 'at least one Program v3 emitter');
|
|
785
787
|
}
|
|
786
788
|
const ids = new Set<string>();
|
|
787
|
-
const emitters:
|
|
789
|
+
const emitters: ParticleEmitterSourceStructure[] = [];
|
|
788
790
|
for (const [index, emitter] of value.emitters.entries()) {
|
|
789
791
|
const parsed = parseEmitter(emitter, index, ids);
|
|
790
792
|
if (!parsed.ok) return parsed;
|
|
@@ -814,14 +816,8 @@ export function parseParticleEffectSourceV2(
|
|
|
814
816
|
}
|
|
815
817
|
return ok(
|
|
816
818
|
Object.freeze({
|
|
817
|
-
schemaVersion:
|
|
819
|
+
schemaVersion: 3,
|
|
818
820
|
emitters: Object.freeze(emitters),
|
|
819
821
|
}),
|
|
820
822
|
);
|
|
821
823
|
}
|
|
822
|
-
|
|
823
|
-
export function defineParticleEffectSourceV2<T extends ParticleEffectSourceV2>(source: T): T {
|
|
824
|
-
const parsed = parseParticleEffectSourceV2(source);
|
|
825
|
-
if (!parsed.ok) throw new TypeError(`${parsed.error.code}: ${parsed.error.expected}`);
|
|
826
|
-
return source;
|
|
827
|
-
}
|
package/src/data-interface.ts
CHANGED
|
@@ -6,11 +6,12 @@ export type VfxDataInterfaceToken = `vfx:${VfxDataInterfaceKind}`;
|
|
|
6
6
|
|
|
7
7
|
export type VfxDataInterfaceBindingType = VfxDataInterfaceBindingTypeByKind[VfxDataInterfaceKind];
|
|
8
8
|
|
|
9
|
+
export type VfxDataInterfaceResourceKind = 'buffer' | 'texture-view' | 'sampler';
|
|
10
|
+
|
|
9
11
|
type VfxDataInterfaceBindingTypeByKind = {
|
|
10
12
|
readonly camera: 'uniform';
|
|
11
13
|
readonly 'scene-depth': 'sampled-depth';
|
|
12
14
|
readonly noise: 'sampled-float';
|
|
13
|
-
readonly channel: 'storage-read';
|
|
14
15
|
};
|
|
15
16
|
|
|
16
17
|
export type VfxDataInterfaceLifetime = 'generation';
|
|
@@ -21,6 +22,8 @@ export interface VfxDataInterfaceRequirement {
|
|
|
21
22
|
readonly binding: number;
|
|
22
23
|
readonly bindingType: VfxDataInterfaceBindingType;
|
|
23
24
|
readonly lifetime: VfxDataInterfaceLifetime;
|
|
25
|
+
/** Scene-depth composition is intentionally proven only for single-sample targets. */
|
|
26
|
+
readonly sampleCount?: 1;
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
export interface VfxDataInterfaceResource {
|
|
@@ -28,6 +31,18 @@ export interface VfxDataInterfaceResource {
|
|
|
28
31
|
readonly kind: VfxDataInterfaceKind;
|
|
29
32
|
readonly bindingType: VfxDataInterfaceBindingType;
|
|
30
33
|
readonly generation: number;
|
|
34
|
+
readonly sampleCount?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Provider-owned opaque resource. Render owners refine this to their typed
|
|
37
|
+
* prepared-resource reference; VFX never inspects a backend handle.
|
|
38
|
+
*/
|
|
39
|
+
readonly resource?: {
|
|
40
|
+
readonly kind: 'buffer' | 'texture-view' | 'sampler';
|
|
41
|
+
readonly value: unknown;
|
|
42
|
+
/** Required when a provider supplies an external buffer to a compute bind group. */
|
|
43
|
+
readonly size?: number;
|
|
44
|
+
readonly usage?: 'uniform' | 'storage';
|
|
45
|
+
};
|
|
31
46
|
}
|
|
32
47
|
|
|
33
48
|
export interface VfxDataInterfaceErrorDetail {
|
|
@@ -37,6 +52,8 @@ export interface VfxDataInterfaceErrorDetail {
|
|
|
37
52
|
readonly actualGeneration?: number;
|
|
38
53
|
readonly expectedBindingType?: VfxDataInterfaceBindingType;
|
|
39
54
|
readonly actualBindingType?: VfxDataInterfaceBindingType;
|
|
55
|
+
readonly expectedResourceKind?: VfxDataInterfaceResourceKind;
|
|
56
|
+
readonly actualResourceKind?: VfxDataInterfaceResourceKind;
|
|
40
57
|
}
|
|
41
58
|
|
|
42
59
|
export interface VfxDataInterfaceError {
|
|
@@ -64,6 +81,16 @@ export interface VfxDataInterfaceResolution {
|
|
|
64
81
|
readonly resources: readonly VfxDataInterfaceResource[];
|
|
65
82
|
}
|
|
66
83
|
|
|
84
|
+
function expectedResourceKind(kind: VfxDataInterfaceKind): VfxDataInterfaceResourceKind {
|
|
85
|
+
switch (kind) {
|
|
86
|
+
case 'camera':
|
|
87
|
+
return 'buffer';
|
|
88
|
+
case 'scene-depth':
|
|
89
|
+
case 'noise':
|
|
90
|
+
return 'texture-view';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
67
94
|
function failure(
|
|
68
95
|
code: VfxDataInterfaceError['code'],
|
|
69
96
|
requirement: VfxDataInterfaceRequirement,
|
|
@@ -160,6 +187,44 @@ export function resolveVfxDataInterfaces(
|
|
|
160
187
|
),
|
|
161
188
|
);
|
|
162
189
|
}
|
|
190
|
+
const expectedKind = expectedResourceKind(requirement.kind);
|
|
191
|
+
if (resource.resource === undefined) {
|
|
192
|
+
return err(
|
|
193
|
+
failure(
|
|
194
|
+
'vfx-data-interface-missing',
|
|
195
|
+
requirement,
|
|
196
|
+
`${requirement.kind} backed by a resident ${expectedKind} resource`,
|
|
197
|
+
`refresh provider ${provider.id} with a generation-owned resource before dispatch`,
|
|
198
|
+
{ providerId: provider.id, expectedResourceKind: expectedKind },
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (resource.resource.kind !== expectedKind) {
|
|
203
|
+
return err(
|
|
204
|
+
failure(
|
|
205
|
+
'vfx-data-interface-wrong-type',
|
|
206
|
+
requirement,
|
|
207
|
+
`${requirement.kind} backed by a ${expectedKind} resource`,
|
|
208
|
+
`repair provider ${provider.id} so its opaque resource matches the reflected binding kind`,
|
|
209
|
+
{
|
|
210
|
+
providerId: provider.id,
|
|
211
|
+
expectedResourceKind: expectedKind,
|
|
212
|
+
actualResourceKind: resource.resource.kind,
|
|
213
|
+
},
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (requirement.sampleCount !== undefined && resource.sampleCount !== requirement.sampleCount) {
|
|
218
|
+
return err(
|
|
219
|
+
failure(
|
|
220
|
+
'vfx-data-interface-wrong-type',
|
|
221
|
+
requirement,
|
|
222
|
+
`a scene-depth resource with sampleCount=${requirement.sampleCount}`,
|
|
223
|
+
`provide a single-sample scene depth target for ${provider.id}; MSAA resolve is not implicit`,
|
|
224
|
+
{ providerId: provider.id },
|
|
225
|
+
),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
163
228
|
if (resource.generation !== generation) {
|
|
164
229
|
return err(
|
|
165
230
|
failure(
|
package/src/effect-contract.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { err, ok, type Result } from '@forgeax/engine-types';
|
|
2
2
|
import type { VfxDataInterfaceRequirement } from './data-interface.js';
|
|
3
|
+
import type { VfxCustomLayout, VfxParticleCoreLayout } from './particle-layout.js';
|
|
3
4
|
|
|
4
5
|
export type VfxValue = number | readonly number[];
|
|
5
6
|
export type VfxValueMap = Readonly<Record<string, VfxValue>>;
|
|
@@ -21,9 +22,12 @@ export interface VfxReflectedStruct {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export interface VfxEffectReflection {
|
|
24
|
-
|
|
25
|
+
/** The single executable particle data ABI. */
|
|
26
|
+
readonly version: 3;
|
|
25
27
|
readonly parameters: VfxReflectedStruct;
|
|
26
28
|
readonly custom: VfxReflectedStruct;
|
|
29
|
+
readonly core: VfxParticleCoreLayout;
|
|
30
|
+
readonly customLayout: VfxCustomLayout;
|
|
27
31
|
readonly dataInterfaces?: readonly VfxDataInterfaceRequirement[];
|
|
28
32
|
readonly fingerprint: string;
|
|
29
33
|
}
|
|
@@ -48,10 +52,16 @@ export interface VfxEffectContract<Values extends VfxValueMap = VfxValueMap> {
|
|
|
48
52
|
readonly reflection: VfxEffectReflection;
|
|
49
53
|
readonly fingerprint: string;
|
|
50
54
|
readonly packedSize: number;
|
|
55
|
+
/** Bytes in the effect-instance Parameters uniform. */
|
|
56
|
+
readonly parameterSize: number;
|
|
57
|
+
/** Bytes in one persistent per-particle Custom record. */
|
|
58
|
+
readonly customStride: number;
|
|
51
59
|
readonly defaults: Values;
|
|
52
60
|
createValues(initial?: Partial<Values>): Result<Values, VfxEffectContractError>;
|
|
53
61
|
validateValues(values: VfxValueMap): Result<Values, VfxEffectContractError>;
|
|
54
62
|
pack(values: Values): Result<Uint8Array, VfxEffectContractError>;
|
|
63
|
+
packParameters(values: Values): Result<Uint8Array, VfxEffectContractError>;
|
|
64
|
+
packCustom(values: VfxValueMap): Result<Uint8Array, VfxEffectContractError>;
|
|
55
65
|
}
|
|
56
66
|
|
|
57
67
|
const VECTOR_LENGTH = {
|
|
@@ -65,8 +75,13 @@ const VECTOR_LENGTH = {
|
|
|
65
75
|
|
|
66
76
|
export type VfxValueType = keyof typeof VECTOR_LENGTH;
|
|
67
77
|
|
|
68
|
-
function fieldList(
|
|
69
|
-
|
|
78
|
+
function fieldList(
|
|
79
|
+
reflection: VfxEffectReflection,
|
|
80
|
+
includeCustom = false,
|
|
81
|
+
): readonly VfxReflectedField[] {
|
|
82
|
+
return includeCustom
|
|
83
|
+
? [...reflection.parameters.fields, ...reflection.custom.fields]
|
|
84
|
+
: reflection.parameters.fields;
|
|
70
85
|
}
|
|
71
86
|
|
|
72
87
|
function zeroValue(type: VfxValueType): VfxValue {
|
|
@@ -101,14 +116,29 @@ function fail(
|
|
|
101
116
|
|
|
102
117
|
function validateReflection(reflection: VfxEffectReflection): Result<true, VfxEffectContractError> {
|
|
103
118
|
if (
|
|
104
|
-
reflection.version !==
|
|
119
|
+
reflection.version !== 3 ||
|
|
105
120
|
typeof reflection.fingerprint !== 'string' ||
|
|
106
121
|
!reflection.fingerprint.startsWith('sha256:')
|
|
107
122
|
) {
|
|
108
123
|
return fail(
|
|
109
124
|
'vfx-reflection-invalid',
|
|
110
125
|
'reflection',
|
|
111
|
-
'a version
|
|
126
|
+
'a version 3 reflection with a sha256 fingerprint',
|
|
127
|
+
'recook the effect with the current VFX compiler',
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (
|
|
131
|
+
reflection.core.name !== 'VfxParticle' ||
|
|
132
|
+
reflection.core.stride !== 112 ||
|
|
133
|
+
reflection.core.fields.length === 0 ||
|
|
134
|
+
reflection.customLayout.name !== 'VfxCustom' ||
|
|
135
|
+
reflection.customLayout.stride < 0 ||
|
|
136
|
+
reflection.customLayout.lanes < 0
|
|
137
|
+
) {
|
|
138
|
+
return fail(
|
|
139
|
+
'vfx-reflection-invalid',
|
|
140
|
+
'reflection.core',
|
|
141
|
+
'Program v3 Core and Custom layouts derived from the particle schema',
|
|
112
142
|
'recook the effect with the current VFX compiler',
|
|
113
143
|
);
|
|
114
144
|
}
|
|
@@ -140,14 +170,21 @@ function validateMap<Values extends VfxValueMap>(
|
|
|
140
170
|
reflection: VfxEffectReflection,
|
|
141
171
|
values: VfxValueMap,
|
|
142
172
|
): Result<Values, VfxEffectContractError> {
|
|
143
|
-
|
|
173
|
+
return validateMapAgainstFields<Values>(fieldList(reflection), values);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function validateMapAgainstFields<Values extends VfxValueMap>(
|
|
177
|
+
reflectedFields: readonly VfxReflectedField[],
|
|
178
|
+
values: VfxValueMap,
|
|
179
|
+
): Result<Values, VfxEffectContractError> {
|
|
180
|
+
const fields = new Map(reflectedFields.map((field) => [field.name, field]));
|
|
144
181
|
for (const name of Object.keys(values)) {
|
|
145
182
|
const field = fields.get(name);
|
|
146
183
|
if (field === undefined) {
|
|
147
184
|
return fail(
|
|
148
185
|
'vfx-value-unknown-field',
|
|
149
186
|
name,
|
|
150
|
-
'a field declared by
|
|
187
|
+
'a field declared by the selected VFX data scope',
|
|
151
188
|
`remove ${name} or declare it in the authored WGSL struct`,
|
|
152
189
|
values[name],
|
|
153
190
|
);
|
|
@@ -181,13 +218,33 @@ export function createVfxEffectContract<Values extends VfxValueMap = VfxValueMap
|
|
|
181
218
|
const checked = validateReflection(reflection);
|
|
182
219
|
if (!checked.ok) throw new TypeError(checked.error.hint);
|
|
183
220
|
const defaults = defaultsFor<Values>(reflection);
|
|
184
|
-
const fields = fieldList(reflection);
|
|
185
221
|
const customBase = reflection.parameters.size;
|
|
186
|
-
const
|
|
222
|
+
const parameterSize = reflection.parameters.size;
|
|
223
|
+
const customStride = reflection.customLayout.stride;
|
|
224
|
+
const packedSize = customBase;
|
|
225
|
+
const packFields = (
|
|
226
|
+
values: VfxValueMap,
|
|
227
|
+
selected: readonly VfxReflectedField[],
|
|
228
|
+
size: number,
|
|
229
|
+
baseOffset: number,
|
|
230
|
+
): Result<Uint8Array, VfxEffectContractError> => {
|
|
231
|
+
const checkedValues = validateMapAgainstFields<Values>(selected, values);
|
|
232
|
+
if (!checkedValues.ok) return checkedValues;
|
|
233
|
+
const bytes = new Uint8Array(size);
|
|
234
|
+
const view = new DataView(bytes.buffer);
|
|
235
|
+
for (const field of selected) {
|
|
236
|
+
const value = checkedValues.value[field.name];
|
|
237
|
+
if (value === undefined) continue;
|
|
238
|
+
writeValue(view, baseOffset + field.offset, field.type, value);
|
|
239
|
+
}
|
|
240
|
+
return ok(bytes);
|
|
241
|
+
};
|
|
187
242
|
return {
|
|
188
243
|
reflection,
|
|
189
244
|
fingerprint: reflection.fingerprint,
|
|
190
245
|
packedSize,
|
|
246
|
+
parameterSize: reflection.parameters.size,
|
|
247
|
+
customStride,
|
|
191
248
|
defaults,
|
|
192
249
|
createValues(initial = {} as Partial<Values>) {
|
|
193
250
|
const merged = { ...defaults, ...initial };
|
|
@@ -197,17 +254,13 @@ export function createVfxEffectContract<Values extends VfxValueMap = VfxValueMap
|
|
|
197
254
|
return validateMap<Values>(reflection, values);
|
|
198
255
|
},
|
|
199
256
|
pack(values) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const base = reflection.parameters.fields.includes(field) ? 0 : customBase;
|
|
208
|
-
writeValue(view, base + field.offset, field.type, value);
|
|
209
|
-
}
|
|
210
|
-
return ok(bytes);
|
|
257
|
+
return packFields(values, reflection.parameters.fields, parameterSize, 0);
|
|
258
|
+
},
|
|
259
|
+
packParameters(values) {
|
|
260
|
+
return packFields(values, reflection.parameters.fields, parameterSize, 0);
|
|
261
|
+
},
|
|
262
|
+
packCustom(values) {
|
|
263
|
+
return packFields(values, reflection.custom.fields, customStride, 0);
|
|
211
264
|
},
|
|
212
265
|
};
|
|
213
266
|
}
|
package/src/gpu-loader.ts
CHANGED
|
@@ -12,16 +12,17 @@ import type {
|
|
|
12
12
|
import { err, ok } from '@forgeax/engine-types';
|
|
13
13
|
import {
|
|
14
14
|
VFX_GPU_PROGRAM_ARTIFACT_KEY,
|
|
15
|
-
|
|
16
|
-
type
|
|
17
|
-
type
|
|
15
|
+
VFX_GPU_PROGRAM_FORMAT_V3,
|
|
16
|
+
type VfxGpuEffectAssetAny,
|
|
17
|
+
type VfxGpuEmitterProgramV3,
|
|
18
18
|
} from './gpu-program.js';
|
|
19
19
|
|
|
20
20
|
export interface VfxGpuAssetError {
|
|
21
21
|
readonly code:
|
|
22
|
-
| 'vfx-asset-
|
|
23
|
-
| 'vfx-asset-
|
|
24
|
-
| 'vfx-asset-
|
|
22
|
+
| 'vfx-asset-version-unsupported'
|
|
23
|
+
| 'vfx-asset-v3-invalid'
|
|
24
|
+
| 'vfx-asset-v3-program-missing'
|
|
25
|
+
| 'vfx-asset-v3-fingerprint-mismatch';
|
|
25
26
|
readonly expected: string;
|
|
26
27
|
readonly hint: string;
|
|
27
28
|
readonly detail: { readonly guid: string; readonly path: string };
|
|
@@ -70,20 +71,26 @@ async function fingerprint(bytes: Uint8Array): Promise<string> {
|
|
|
70
71
|
|
|
71
72
|
function validReflectionLayout(value: unknown): boolean {
|
|
72
73
|
if (value === undefined) return true;
|
|
73
|
-
if (!record(value) || value.version !==
|
|
74
|
+
if (!record(value) || value.version !== 3) return false;
|
|
74
75
|
if (!record(value.parameters) || !Array.isArray(value.parameters.fields)) return false;
|
|
75
76
|
if (!record(value.custom) || !Array.isArray(value.custom.fields)) return false;
|
|
77
|
+
if (value.version === 3) {
|
|
78
|
+
if (!record(value.core) || value.core.name !== 'VfxParticle' || value.core.stride !== 112) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
if (!record(value.customLayout) || value.customLayout.name !== 'VfxCustom') return false;
|
|
82
|
+
}
|
|
76
83
|
return typeof value.fingerprint === 'string' && value.fingerprint.startsWith('sha256:');
|
|
77
84
|
}
|
|
78
85
|
|
|
79
|
-
function stableLayouts(emitters: readonly
|
|
86
|
+
function stableLayouts(emitters: readonly VfxGpuEmitterProgramV3[]): boolean {
|
|
80
87
|
const fingerprints = emitters
|
|
81
88
|
.map((emitter) => emitter.reflection.layout?.fingerprint)
|
|
82
89
|
.filter((fingerprint): fingerprint is string => fingerprint !== undefined);
|
|
83
90
|
return fingerprints.every((fingerprint) => fingerprint === fingerprints[0]);
|
|
84
91
|
}
|
|
85
92
|
|
|
86
|
-
function validEmitter(value: unknown):
|
|
93
|
+
function validEmitter(value: unknown): boolean {
|
|
87
94
|
const reflection = record(value) && record(value.reflection) ? value.reflection : undefined;
|
|
88
95
|
const layout = reflection !== undefined ? reflection.layout : undefined;
|
|
89
96
|
return (
|
|
@@ -107,7 +114,13 @@ function validEmitter(value: unknown): value is VfxGpuEmitterProgram {
|
|
|
107
114
|
value.reflection.entryPoints.includes('forgeax_vfx_mesh_main') &&
|
|
108
115
|
value.reflection.entryPoints.includes('forgeax_vfx_ribbon_main') &&
|
|
109
116
|
value.reflection.entryPoints.includes('forgeax_vfx_trail_main') &&
|
|
110
|
-
value.reflection.entryPoints.includes('forgeax_vfx_beam_main')
|
|
117
|
+
value.reflection.entryPoints.includes('forgeax_vfx_beam_main') &&
|
|
118
|
+
layout !== undefined &&
|
|
119
|
+
record(layout) &&
|
|
120
|
+
Array.isArray(reflection?.bindings) &&
|
|
121
|
+
Array.isArray(reflection?.dataInterfaces) &&
|
|
122
|
+
Array.isArray(reflection?.renderers) &&
|
|
123
|
+
Array.isArray(reflection?.resources)
|
|
111
124
|
);
|
|
112
125
|
}
|
|
113
126
|
|
|
@@ -116,26 +129,39 @@ export const vfxGpuEffectPackLoader = {
|
|
|
116
129
|
async load(
|
|
117
130
|
input: PackLoaderInput,
|
|
118
131
|
_context: LoadContext,
|
|
119
|
-
): Promise<Result<
|
|
132
|
+
): Promise<Result<VfxGpuEffectAssetAny, VfxGpuAssetError>> {
|
|
133
|
+
const schemaVersion = input.payload.schemaVersion;
|
|
134
|
+
if (schemaVersion !== 3) {
|
|
135
|
+
return failure(
|
|
136
|
+
'vfx-asset-version-unsupported',
|
|
137
|
+
input.guid,
|
|
138
|
+
'schemaVersion',
|
|
139
|
+
'schemaVersion 3 / forgeax-vfx-program-3',
|
|
140
|
+
'cold-cook the older source and publish a Program v3 payload; older versions are not executable',
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const invalidCode = 'vfx-asset-v3-invalid' as const;
|
|
144
|
+
const missingCode = 'vfx-asset-v3-program-missing' as const;
|
|
145
|
+
const fingerprintCode = 'vfx-asset-v3-fingerprint-mismatch' as const;
|
|
146
|
+
const expectedFormat = VFX_GPU_PROGRAM_FORMAT_V3;
|
|
120
147
|
if (
|
|
121
148
|
input.payload.kind !== 'particle-effect' ||
|
|
122
|
-
input.payload.schemaVersion !== 2 ||
|
|
123
149
|
!Array.isArray(input.payload.emitters) ||
|
|
124
150
|
typeof input.payload.programFingerprint !== 'string' ||
|
|
125
151
|
!record(input.payload.program)
|
|
126
152
|
) {
|
|
127
153
|
return failure(
|
|
128
|
-
|
|
154
|
+
invalidCode,
|
|
129
155
|
input.guid,
|
|
130
156
|
'payload',
|
|
131
|
-
'a schemaVersion
|
|
132
|
-
'
|
|
157
|
+
'a schemaVersion 3 particle payload with its complete Program v3 and fingerprint',
|
|
158
|
+
'cold-cook the source with the current VFX compiler and retry the load',
|
|
133
159
|
);
|
|
134
160
|
}
|
|
135
161
|
const artifact = input.artifacts[VFX_GPU_PROGRAM_ARTIFACT_KEY];
|
|
136
162
|
if (artifact === undefined) {
|
|
137
163
|
return failure(
|
|
138
|
-
|
|
164
|
+
missingCode,
|
|
139
165
|
input.guid,
|
|
140
166
|
VFX_GPU_PROGRAM_ARTIFACT_KEY,
|
|
141
167
|
'the asset-local cooked VFX program',
|
|
@@ -147,7 +173,7 @@ export const vfxGpuEffectPackLoader = {
|
|
|
147
173
|
decoded = JSON.parse(new TextDecoder().decode(artifact.bytes));
|
|
148
174
|
} catch {
|
|
149
175
|
return failure(
|
|
150
|
-
|
|
176
|
+
invalidCode,
|
|
151
177
|
input.guid,
|
|
152
178
|
VFX_GPU_PROGRAM_ARTIFACT_KEY,
|
|
153
179
|
'canonical JSON program bytes',
|
|
@@ -156,27 +182,27 @@ export const vfxGpuEffectPackLoader = {
|
|
|
156
182
|
}
|
|
157
183
|
if (
|
|
158
184
|
!record(decoded) ||
|
|
159
|
-
decoded.format !==
|
|
185
|
+
decoded.format !== expectedFormat ||
|
|
160
186
|
!Array.isArray(decoded.emitters) ||
|
|
161
|
-
!decoded.emitters.every(validEmitter) ||
|
|
162
|
-
!stableLayouts(decoded.emitters as
|
|
187
|
+
!decoded.emitters.every((emitter) => validEmitter(emitter)) ||
|
|
188
|
+
!stableLayouts(decoded.emitters as VfxGpuEmitterProgramV3[])
|
|
163
189
|
) {
|
|
164
190
|
return failure(
|
|
165
|
-
|
|
191
|
+
invalidCode,
|
|
166
192
|
input.guid,
|
|
167
193
|
VFX_GPU_PROGRAM_ARTIFACT_KEY,
|
|
168
|
-
`a ${
|
|
194
|
+
`a ${expectedFormat} managed GPU program`,
|
|
169
195
|
'recook with the current VFX compiler ABI',
|
|
170
196
|
);
|
|
171
197
|
}
|
|
172
|
-
const decodedEmitters = decoded.emitters as
|
|
198
|
+
const decodedEmitters = decoded.emitters as VfxGpuEmitterProgramV3[];
|
|
173
199
|
if (
|
|
174
|
-
input.payload.program.format !==
|
|
200
|
+
input.payload.program.format !== expectedFormat ||
|
|
175
201
|
input.payload.program.fingerprint !== input.payload.programFingerprint ||
|
|
176
202
|
!Array.isArray(input.payload.program.emitters)
|
|
177
203
|
) {
|
|
178
204
|
return failure(
|
|
179
|
-
|
|
205
|
+
invalidCode,
|
|
180
206
|
input.guid,
|
|
181
207
|
'payload.program',
|
|
182
208
|
'the complete canonical program matching payload.programFingerprint',
|
|
@@ -186,7 +212,7 @@ export const vfxGpuEffectPackLoader = {
|
|
|
186
212
|
const actualFingerprint = await fingerprint(artifact.bytes);
|
|
187
213
|
if (actualFingerprint !== input.payload.programFingerprint) {
|
|
188
214
|
return failure(
|
|
189
|
-
|
|
215
|
+
fingerprintCode,
|
|
190
216
|
input.guid,
|
|
191
217
|
'payload.programFingerprint',
|
|
192
218
|
'payload and program artifact fingerprints to match',
|
|
@@ -209,7 +235,7 @@ export const vfxGpuEffectPackLoader = {
|
|
|
209
235
|
)
|
|
210
236
|
) {
|
|
211
237
|
return failure(
|
|
212
|
-
|
|
238
|
+
invalidCode,
|
|
213
239
|
input.guid,
|
|
214
240
|
'payload.emitters',
|
|
215
241
|
'payload emitter identities and capacities to match the program',
|
|
@@ -219,26 +245,26 @@ export const vfxGpuEffectPackLoader = {
|
|
|
219
245
|
return ok(
|
|
220
246
|
Object.freeze({
|
|
221
247
|
guid: input.guid,
|
|
222
|
-
kind: 'particle-effect',
|
|
223
|
-
schemaVersion:
|
|
248
|
+
kind: 'particle-effect' as const,
|
|
249
|
+
schemaVersion: 3,
|
|
224
250
|
programFingerprint: actualFingerprint,
|
|
225
251
|
emitters: Object.freeze(emitters as { id: string; capacity: number }[]),
|
|
226
252
|
program: Object.freeze({
|
|
227
|
-
format:
|
|
253
|
+
format: expectedFormat,
|
|
228
254
|
fingerprint: actualFingerprint,
|
|
229
255
|
emitters: Object.freeze(decodedEmitters),
|
|
230
256
|
}),
|
|
231
|
-
}),
|
|
257
|
+
}) as VfxGpuEffectAssetAny,
|
|
232
258
|
);
|
|
233
259
|
},
|
|
234
260
|
};
|
|
235
261
|
|
|
236
262
|
/** VFX owner decoder contribution for the core registry's typed map. */
|
|
237
263
|
export const vfxGpuEffectContribution: AssetDecoderContribution<
|
|
238
|
-
|
|
264
|
+
VfxGpuEffectAssetAny,
|
|
239
265
|
'particle-effect'
|
|
240
266
|
> = {
|
|
241
|
-
kind: { kind: 'particle-effect' } as AssetKind<
|
|
267
|
+
kind: { kind: 'particle-effect' } as AssetKind<VfxGpuEffectAssetAny, 'particle-effect'>,
|
|
242
268
|
consumer: 'VfxGpuRuntime',
|
|
243
269
|
decoder: {
|
|
244
270
|
async decode({ envelope, artifacts }) {
|
|
@@ -277,7 +303,7 @@ export const vfxGpuEffectContribution: AssetDecoderContribution<
|
|
|
277
303
|
export async function loadVfxGpuEffect(
|
|
278
304
|
registry: LegacyAssetRegistry | RuntimeAssetRegistry,
|
|
279
305
|
guid: string,
|
|
280
|
-
): Promise<Result<
|
|
306
|
+
): Promise<Result<VfxGpuEffectAssetAny, unknown>> {
|
|
281
307
|
if ('load' in registry) {
|
|
282
308
|
return registry.load(guid, vfxGpuEffectContribution.kind);
|
|
283
309
|
}
|
|
@@ -287,5 +313,5 @@ export async function loadVfxGpuEffect(
|
|
|
287
313
|
} catch (error) {
|
|
288
314
|
return err(error);
|
|
289
315
|
}
|
|
290
|
-
return registry.loadByGuid<
|
|
316
|
+
return registry.loadByGuid<VfxGpuEffectAssetAny>(parsed);
|
|
291
317
|
}
|