@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.
- package/LICENSE +202 -0
- package/README.md +214 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/authoring-descriptor.unit.test.d.ts +2 -0
- package/dist/__tests__/authoring-descriptor.unit.test.d.ts.map +1 -0
- package/dist/__tests__/channel-input.unit.test.d.ts +2 -0
- package/dist/__tests__/channel-input.unit.test.d.ts.map +1 -0
- package/dist/__tests__/channel-inspect.unit.test.d.ts +2 -0
- package/dist/__tests__/channel-inspect.unit.test.d.ts.map +1 -0
- package/dist/__tests__/code-source-v2.unit.test.d.ts +2 -0
- package/dist/__tests__/code-source-v2.unit.test.d.ts.map +1 -0
- package/dist/__tests__/data-interface-contract.unit.test.d.ts +2 -0
- package/dist/__tests__/data-interface-contract.unit.test.d.ts.map +1 -0
- package/dist/__tests__/effect-contract.unit.test.d.ts +2 -0
- package/dist/__tests__/effect-contract.unit.test.d.ts.map +1 -0
- package/dist/__tests__/gpu-program-simulation-culling-owner.test-d.d.ts +2 -0
- package/dist/__tests__/gpu-program-simulation-culling-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/gpu-reflection-vocabulary-owner.test-d.d.ts +2 -0
- package/dist/__tests__/gpu-reflection-vocabulary-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/gpu-runtime.integration.test.d.ts +2 -0
- package/dist/__tests__/gpu-runtime.integration.test.d.ts.map +1 -0
- package/dist/__tests__/inspect-snapshot.unit.test.d.ts +2 -0
- package/dist/__tests__/inspect-snapshot.unit.test.d.ts.map +1 -0
- package/dist/__tests__/instance-public-api.unit.test.d.ts +2 -0
- package/dist/__tests__/instance-public-api.unit.test.d.ts.map +1 -0
- package/dist/__tests__/instance.unit.test.d.ts +2 -0
- package/dist/__tests__/instance.unit.test.d.ts.map +1 -0
- package/dist/__tests__/loader-v1-rejection.unit.test.d.ts +2 -0
- package/dist/__tests__/loader-v1-rejection.unit.test.d.ts.map +1 -0
- package/dist/__tests__/player-reflection.unit.test.d.ts +2 -0
- package/dist/__tests__/player-reflection.unit.test.d.ts.map +1 -0
- package/dist/__tests__/player-type.test-d.d.ts +2 -0
- package/dist/__tests__/player-type.test-d.d.ts.map +1 -0
- package/dist/__tests__/renderer-source.unit.test.d.ts +2 -0
- package/dist/__tests__/renderer-source.unit.test.d.ts.map +1 -0
- package/dist/__tests__/replay-canonical.unit.test.d.ts +2 -0
- package/dist/__tests__/replay-canonical.unit.test.d.ts.map +1 -0
- package/dist/__tests__/runtime-dist-isolation.test.d.ts +2 -0
- package/dist/__tests__/runtime-dist-isolation.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts +2 -0
- package/dist/__tests__/scriptable-pack-consumer.test-d.d.ts.map +1 -0
- package/dist/__tests__/stage-source.unit.test.d.ts +2 -0
- package/dist/__tests__/stage-source.unit.test.d.ts.map +1 -0
- package/dist/__tests__/vfx-data-interface-vocabulary-owner.test-d.d.ts +2 -0
- package/dist/__tests__/vfx-data-interface-vocabulary-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/vfx-performance-contract.unit.test.d.ts +2 -0
- package/dist/__tests__/vfx-performance-contract.unit.test.d.ts.map +1 -0
- package/dist/__tests__/vfx-value-type-owner.test-d.d.ts +2 -0
- package/dist/__tests__/vfx-value-type-owner.test-d.d.ts.map +1 -0
- package/dist/assets/particle-effect-decoder.d.ts +3 -0
- package/dist/assets/particle-effect-decoder.d.ts.map +1 -0
- package/dist/authoring-descriptor.d.ts +60 -0
- package/dist/authoring-descriptor.d.ts.map +1 -0
- package/dist/code-source.d.ts +140 -0
- package/dist/code-source.d.ts.map +1 -0
- package/dist/data-interface.d.ts +53 -0
- package/dist/data-interface.d.ts.map +1 -0
- package/dist/effect-contract.d.ts +57 -0
- package/dist/effect-contract.d.ts.map +1 -0
- package/dist/gpu-loader.d.ts +33 -0
- package/dist/gpu-loader.d.ts.map +1 -0
- package/dist/gpu-program.d.ts +80 -0
- package/dist/gpu-program.d.ts.map +1 -0
- package/dist/gpu-runtime.d.ts +174 -0
- package/dist/gpu-runtime.d.ts.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1986 -0
- package/dist/index.mjs.map +1 -0
- package/dist/instance.d.ts +86 -0
- package/dist/instance.d.ts.map +1 -0
- package/dist/player.d.ts +17 -0
- package/dist/player.d.ts.map +1 -0
- package/package.json +66 -0
- package/src/__tests__/authoring-descriptor.unit.test.ts +138 -0
- package/src/__tests__/channel-input.unit.test.ts +65 -0
- package/src/__tests__/channel-inspect.unit.test.ts +65 -0
- package/src/__tests__/code-source-v2.unit.test.ts +121 -0
- package/src/__tests__/data-interface-contract.unit.test.ts +92 -0
- package/src/__tests__/effect-contract.unit.test.ts +62 -0
- package/src/__tests__/gpu-program-simulation-culling-owner.test-d.ts +58 -0
- package/src/__tests__/gpu-reflection-vocabulary-owner.test-d.ts +23 -0
- package/src/__tests__/gpu-runtime.integration.test.ts +549 -0
- package/src/__tests__/inspect-snapshot.unit.test.ts +51 -0
- package/src/__tests__/instance-public-api.unit.test.ts +27 -0
- package/src/__tests__/instance.unit.test.ts +107 -0
- package/src/__tests__/loader-v1-rejection.unit.test.ts +125 -0
- package/src/__tests__/player-reflection.unit.test.ts +91 -0
- package/src/__tests__/player-type.test-d.ts +54 -0
- package/src/__tests__/renderer-source.unit.test.ts +58 -0
- package/src/__tests__/replay-canonical.unit.test.ts +68 -0
- package/src/__tests__/runtime-dist-isolation.test.ts +100 -0
- package/src/__tests__/scriptable-pack-consumer.test-d.ts +9 -0
- package/src/__tests__/stage-source.unit.test.ts +64 -0
- package/src/__tests__/vfx-data-interface-vocabulary-owner.test-d.ts +34 -0
- package/src/__tests__/vfx-performance-contract.unit.test.ts +19 -0
- package/src/__tests__/vfx-value-type-owner.test-d.ts +32 -0
- package/src/assets/particle-effect-decoder.ts +34 -0
- package/src/authoring-descriptor.ts +355 -0
- package/src/code-source.ts +827 -0
- package/src/data-interface.ts +181 -0
- package/src/effect-contract.ts +220 -0
- package/src/gpu-loader.ts +279 -0
- package/src/gpu-program.ts +101 -0
- package/src/gpu-runtime.ts +807 -0
- package/src/index.ts +110 -0
- package/src/instance.ts +379 -0
- package/src/player.ts +21 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// @forgeax/engine-vfx - runtime-safe code-first GPU VFX contract.
|
|
2
|
+
|
|
3
|
+
export type { ParticleEffectAsset, ParticleEmitterDefinition } from '@forgeax/engine-types';
|
|
4
|
+
export { particleEffectContribution } from './assets/particle-effect-decoder.js';
|
|
5
|
+
export type {
|
|
6
|
+
VfxAuthoringCapabilityDescriptor,
|
|
7
|
+
VfxAuthoringDependencyDescriptor,
|
|
8
|
+
VfxAuthoringDescriptor,
|
|
9
|
+
VfxAuthoringEmitterDescriptor,
|
|
10
|
+
VfxAuthoringFieldDescriptor,
|
|
11
|
+
VfxAuthoringNodeDescriptor,
|
|
12
|
+
VfxAuthoringNodeRole,
|
|
13
|
+
VfxAuthoringTimelineDescriptor,
|
|
14
|
+
VfxAuthoringValue,
|
|
15
|
+
} from './authoring-descriptor.js';
|
|
16
|
+
export { describeVfxGpuEffect, isVfxGpuEffectAsset } from './authoring-descriptor.js';
|
|
17
|
+
export type {
|
|
18
|
+
ParticleBoundsSource,
|
|
19
|
+
ParticleChannelOverflowPolicy,
|
|
20
|
+
ParticleChannelSource,
|
|
21
|
+
ParticleCodeSourceError,
|
|
22
|
+
ParticleCodeSourceInvalidDetail,
|
|
23
|
+
ParticleEffectRootSourceV2,
|
|
24
|
+
ParticleEffectSourceV2,
|
|
25
|
+
ParticleEmitterSourceV2,
|
|
26
|
+
ParticleEventSource,
|
|
27
|
+
ParticleRendererOverflowPolicy,
|
|
28
|
+
ParticleRendererSorting,
|
|
29
|
+
ParticleRendererSource,
|
|
30
|
+
ParticleStageDomain,
|
|
31
|
+
ParticleStageResourceAccess,
|
|
32
|
+
ParticleStageResourceSource,
|
|
33
|
+
ParticleStageSource,
|
|
34
|
+
} from './code-source.js';
|
|
35
|
+
export {
|
|
36
|
+
defineParticleEffectSourceV2,
|
|
37
|
+
PARTICLE_CODE_DEFAULT_MODULE_ID,
|
|
38
|
+
PARTICLE_STAGE_RESOURCE_NAMES,
|
|
39
|
+
parseParticleEffectSourceV2,
|
|
40
|
+
parseVfxStageDeclarations,
|
|
41
|
+
} from './code-source.js';
|
|
42
|
+
export type {
|
|
43
|
+
VfxDataInterfaceBindingType,
|
|
44
|
+
VfxDataInterfaceError,
|
|
45
|
+
VfxDataInterfaceErrorDetail,
|
|
46
|
+
VfxDataInterfaceKind,
|
|
47
|
+
VfxDataInterfaceLifetime,
|
|
48
|
+
VfxDataInterfaceProvider,
|
|
49
|
+
VfxDataInterfaceRequirement,
|
|
50
|
+
VfxDataInterfaceResolution,
|
|
51
|
+
VfxDataInterfaceResource,
|
|
52
|
+
VfxDataInterfaceToken,
|
|
53
|
+
} from './data-interface.js';
|
|
54
|
+
export { resolveVfxDataInterfaces } from './data-interface.js';
|
|
55
|
+
export type {
|
|
56
|
+
VfxEffectContract,
|
|
57
|
+
VfxEffectContractError,
|
|
58
|
+
VfxEffectContractErrorDetail,
|
|
59
|
+
VfxEffectReflection,
|
|
60
|
+
VfxReflectedField,
|
|
61
|
+
VfxReflectedStruct,
|
|
62
|
+
VfxValue,
|
|
63
|
+
VfxValueMap,
|
|
64
|
+
VfxValueType,
|
|
65
|
+
} from './effect-contract.js';
|
|
66
|
+
export { createVfxEffectContract, validateVfxEffectValues } from './effect-contract.js';
|
|
67
|
+
export type { VfxGpuAssetError } from './gpu-loader.js';
|
|
68
|
+
export {
|
|
69
|
+
loadVfxGpuEffect,
|
|
70
|
+
vfxGpuEffectContribution,
|
|
71
|
+
vfxGpuEffectPackLoader,
|
|
72
|
+
} from './gpu-loader.js';
|
|
73
|
+
export type {
|
|
74
|
+
VfxGpuEffectAsset,
|
|
75
|
+
VfxGpuEmitterProgram,
|
|
76
|
+
VfxGpuProgram,
|
|
77
|
+
VfxGpuProgramReflection,
|
|
78
|
+
VfxGpuStageReflection,
|
|
79
|
+
} from './gpu-program.js';
|
|
80
|
+
export { VFX_GPU_PROGRAM_ARTIFACT_KEY, VFX_GPU_PROGRAM_FORMAT } from './gpu-program.js';
|
|
81
|
+
export type {
|
|
82
|
+
VfxGpuEmitterInspectSnapshot,
|
|
83
|
+
VfxGpuPlayerInspectSnapshot,
|
|
84
|
+
VfxGpuRuntimeDiagnostic,
|
|
85
|
+
VfxGpuRuntimeOptions,
|
|
86
|
+
VfxGpuTickIntent,
|
|
87
|
+
} from './gpu-runtime.js';
|
|
88
|
+
export {
|
|
89
|
+
createVfxInspectSnapshot,
|
|
90
|
+
VFX_GPU_RUNTIME_RESOURCE_KEY,
|
|
91
|
+
VfxGpuRuntime,
|
|
92
|
+
vfxGpuRuntimePlugin,
|
|
93
|
+
} from './gpu-runtime.js';
|
|
94
|
+
export type {
|
|
95
|
+
VfxChannelCounters,
|
|
96
|
+
VfxChannelInput,
|
|
97
|
+
VfxChannelPayload,
|
|
98
|
+
VfxInstanceCommit,
|
|
99
|
+
VfxInstanceCommitOptions,
|
|
100
|
+
VfxInstanceError,
|
|
101
|
+
VfxInstanceOptions,
|
|
102
|
+
VfxInstanceParent,
|
|
103
|
+
VfxReplayInput,
|
|
104
|
+
} from './instance.js';
|
|
105
|
+
export {
|
|
106
|
+
createParticleEffectInstance,
|
|
107
|
+
ParticleEffectInstance,
|
|
108
|
+
} from './instance.js';
|
|
109
|
+
export type { ParticleEffectPlayerData } from './player.js';
|
|
110
|
+
export { ParticleEffectPlayer } from './player.js';
|
package/src/instance.ts
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { err, ok, type Result } from '@forgeax/engine-types';
|
|
2
|
+
import type { ParticleChannelSource } from './code-source.js';
|
|
3
|
+
import type {
|
|
4
|
+
VfxEffectContract,
|
|
5
|
+
VfxEffectContractError,
|
|
6
|
+
VfxValue,
|
|
7
|
+
VfxValueMap,
|
|
8
|
+
VfxValueType,
|
|
9
|
+
} from './effect-contract.js';
|
|
10
|
+
|
|
11
|
+
export interface VfxChannelPayload {
|
|
12
|
+
readonly position: readonly [number, number, number];
|
|
13
|
+
readonly strength: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface VfxChannelInput {
|
|
17
|
+
readonly channel: string;
|
|
18
|
+
readonly payload: VfxChannelPayload;
|
|
19
|
+
readonly sequence: number;
|
|
20
|
+
readonly tick?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface VfxChannelCounters {
|
|
24
|
+
readonly queued: number;
|
|
25
|
+
readonly produced: number;
|
|
26
|
+
readonly consumed: number;
|
|
27
|
+
readonly dropped: number;
|
|
28
|
+
readonly overflow: number;
|
|
29
|
+
readonly fanOut: number;
|
|
30
|
+
readonly recursionDepth: number;
|
|
31
|
+
readonly lastSequence: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface VfxInstanceParent<Values extends VfxValueMap> {
|
|
35
|
+
readonly fingerprint: string;
|
|
36
|
+
readonly defaults: Values;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface VfxInstanceOptions<Values extends VfxValueMap> {
|
|
40
|
+
readonly parent?: VfxInstanceParent<Values>;
|
|
41
|
+
readonly initialValues?: Partial<Values>;
|
|
42
|
+
readonly channels?: readonly ParticleChannelSource[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface VfxInstanceCommitOptions {
|
|
46
|
+
readonly seed: number;
|
|
47
|
+
readonly tick: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface VfxReplayInput<Values extends VfxValueMap> {
|
|
51
|
+
readonly seed: number;
|
|
52
|
+
readonly tick: number;
|
|
53
|
+
readonly generation: number;
|
|
54
|
+
readonly sequence: number;
|
|
55
|
+
readonly fingerprint: string;
|
|
56
|
+
readonly payload: Uint8Array;
|
|
57
|
+
readonly values: Values;
|
|
58
|
+
readonly channelInputs: readonly VfxChannelInput[];
|
|
59
|
+
readonly droppedCount: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface VfxInstanceCommit<Values extends VfxValueMap> {
|
|
63
|
+
readonly seed: number;
|
|
64
|
+
readonly tick: number;
|
|
65
|
+
readonly generation: number;
|
|
66
|
+
readonly sequence: number;
|
|
67
|
+
readonly values: Values;
|
|
68
|
+
readonly parameterBlock: Uint8Array;
|
|
69
|
+
readonly canonicalPayload: Uint8Array;
|
|
70
|
+
readonly replayInput: VfxReplayInput<Values>;
|
|
71
|
+
readonly patchCount: number;
|
|
72
|
+
readonly channelInputs: readonly VfxChannelInput[];
|
|
73
|
+
readonly droppedCount: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface VfxInstanceError {
|
|
77
|
+
readonly code:
|
|
78
|
+
| VfxEffectContractError['code']
|
|
79
|
+
| 'vfx-instance-parent-mismatch'
|
|
80
|
+
| 'vfx-instance-replay-mismatch'
|
|
81
|
+
| 'vfx-channel-invalid'
|
|
82
|
+
| 'vfx-channel-overflow';
|
|
83
|
+
readonly expected: string;
|
|
84
|
+
readonly hint: string;
|
|
85
|
+
readonly detail: { readonly path: string; readonly actual?: unknown };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
type InstanceResult<T> = Result<T, VfxInstanceError>;
|
|
89
|
+
|
|
90
|
+
function failure(
|
|
91
|
+
code: VfxInstanceError['code'],
|
|
92
|
+
path: string,
|
|
93
|
+
expected: string,
|
|
94
|
+
hint: string,
|
|
95
|
+
actual?: unknown,
|
|
96
|
+
): InstanceResult<never> {
|
|
97
|
+
return err({
|
|
98
|
+
code,
|
|
99
|
+
expected,
|
|
100
|
+
hint,
|
|
101
|
+
detail: actual === undefined ? { path } : { path, actual },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function allFields<Values extends VfxValueMap>(
|
|
106
|
+
contract: VfxEffectContract<Values>,
|
|
107
|
+
): readonly { readonly name: string; readonly type: VfxValueType }[] {
|
|
108
|
+
return [...contract.reflection.parameters.fields, ...contract.reflection.custom.fields];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeValue(type: VfxValueType, value: VfxValue): VfxValue {
|
|
112
|
+
const normalize = (component: number): number => {
|
|
113
|
+
if (type === 'i32' || type === 'u32') return Math.trunc(component);
|
|
114
|
+
return Math.fround(component);
|
|
115
|
+
};
|
|
116
|
+
return typeof value === 'number' ? normalize(value) : value.map(normalize);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function canonicalBytes<Values extends VfxValueMap>(
|
|
120
|
+
contract: VfxEffectContract<Values>,
|
|
121
|
+
values: Values,
|
|
122
|
+
): Uint8Array {
|
|
123
|
+
const fields = allFields(contract)
|
|
124
|
+
.map((field) => ({ name: field.name, type: field.type, value: values[field.name] }))
|
|
125
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
126
|
+
.map((field) => ({
|
|
127
|
+
name: field.name,
|
|
128
|
+
type: field.type,
|
|
129
|
+
value: field.value === undefined ? undefined : normalizeValue(field.type, field.value),
|
|
130
|
+
}));
|
|
131
|
+
return new TextEncoder().encode(JSON.stringify({ fields }));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
|
|
135
|
+
if (left.length !== right.length) return false;
|
|
136
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
137
|
+
if (left[index] !== right[index]) return false;
|
|
138
|
+
}
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function cloneValue(value: VfxValue): VfxValue {
|
|
143
|
+
return typeof value === 'number' ? value : [...value];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function cloneValues<Values extends VfxValueMap>(values: Values): Values {
|
|
147
|
+
const clone: Record<string, VfxValue> = {};
|
|
148
|
+
for (const [name, value] of Object.entries(values)) clone[name] = cloneValue(value);
|
|
149
|
+
return Object.freeze(clone) as Values;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function cloneBytes(bytes: Uint8Array): Uint8Array {
|
|
153
|
+
return bytes.slice();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function validChannelPayload(payload: unknown): payload is VfxChannelPayload {
|
|
157
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return false;
|
|
158
|
+
const value = payload as Record<string, unknown>;
|
|
159
|
+
const position = value.position;
|
|
160
|
+
return (
|
|
161
|
+
Array.isArray(position) &&
|
|
162
|
+
position.length === 3 &&
|
|
163
|
+
position.every((component) => typeof component === 'number' && Number.isFinite(component)) &&
|
|
164
|
+
typeof value.strength === 'number' &&
|
|
165
|
+
Number.isFinite(value.strength)
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function withTick(input: VfxChannelInput, tick: number): VfxChannelInput {
|
|
170
|
+
const result = { ...input } as VfxChannelInput & { tick: number };
|
|
171
|
+
Object.defineProperty(result, 'tick', { value: tick, enumerable: false });
|
|
172
|
+
return Object.freeze(result);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export class ParticleEffectInstance<Values extends VfxValueMap = VfxValueMap> {
|
|
176
|
+
readonly contract: VfxEffectContract<Values>;
|
|
177
|
+
#values: Values;
|
|
178
|
+
#generation = 0;
|
|
179
|
+
#sequence = 0;
|
|
180
|
+
#pendingPatches: Partial<Values>[] = [];
|
|
181
|
+
#pendingChannels: VfxChannelInput[] = [];
|
|
182
|
+
#channelCapacities = new Map<string, number>([['impact', 32]]);
|
|
183
|
+
#droppedCount = 0;
|
|
184
|
+
|
|
185
|
+
constructor(contract: VfxEffectContract<Values>, options: VfxInstanceOptions<Values> = {}) {
|
|
186
|
+
this.contract = contract;
|
|
187
|
+
if (
|
|
188
|
+
options.parent?.fingerprint !== undefined &&
|
|
189
|
+
options.parent.fingerprint !== contract.fingerprint
|
|
190
|
+
) {
|
|
191
|
+
throw new TypeError(
|
|
192
|
+
`ParticleEffectInstance parent fingerprint ${options.parent.fingerprint} does not match ${contract.fingerprint}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
const parentDefaults = options.parent?.defaults ?? contract.defaults;
|
|
196
|
+
const initial = { ...parentDefaults, ...options.initialValues };
|
|
197
|
+
const checked = contract.createValues(initial);
|
|
198
|
+
if (!checked.ok) throw new TypeError(checked.error.hint);
|
|
199
|
+
this.#values = cloneValues(checked.value);
|
|
200
|
+
for (const channel of options.channels ?? []) {
|
|
201
|
+
this.#channelCapacities.set(channel.id, channel.capacity);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
get values(): Values {
|
|
206
|
+
return this.#values;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
get generation(): number {
|
|
210
|
+
return this.#generation;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
get pendingPatchCount(): number {
|
|
214
|
+
return this.#pendingPatches.length;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
setChannelCapacity(channel: string, capacity: number): void {
|
|
218
|
+
if (!Number.isInteger(capacity) || capacity <= 0) {
|
|
219
|
+
throw new RangeError('channel capacity must be a positive integer');
|
|
220
|
+
}
|
|
221
|
+
this.#channelCapacities.set(channel, capacity);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
submit(input: VfxChannelInput): InstanceResult<void> {
|
|
225
|
+
if (
|
|
226
|
+
typeof input.channel !== 'string' ||
|
|
227
|
+
input.channel.length === 0 ||
|
|
228
|
+
!Number.isInteger(input.sequence) ||
|
|
229
|
+
input.sequence < 0 ||
|
|
230
|
+
!validChannelPayload(input.payload)
|
|
231
|
+
) {
|
|
232
|
+
return failure(
|
|
233
|
+
'vfx-channel-invalid',
|
|
234
|
+
`channels.${input.channel || 'unknown'}`,
|
|
235
|
+
'a named channel with a finite impact payload and non-negative integer sequence',
|
|
236
|
+
'repair the typed channel payload before the next FixedUpdate',
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
const capacity = this.#channelCapacities.get(input.channel) ?? 32;
|
|
240
|
+
if (this.#pendingChannels.length >= capacity) {
|
|
241
|
+
this.#droppedCount += 1;
|
|
242
|
+
return failure(
|
|
243
|
+
'vfx-channel-overflow',
|
|
244
|
+
`channels.${input.channel}`,
|
|
245
|
+
`at most ${capacity} pending inputs for this channel`,
|
|
246
|
+
'reduce the input rate or increase the reflected channel capacity and recook',
|
|
247
|
+
input.sequence,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
this.#pendingChannels.push(
|
|
251
|
+
Object.freeze({
|
|
252
|
+
channel: input.channel,
|
|
253
|
+
payload: Object.freeze({
|
|
254
|
+
position: [...input.payload.position] as [number, number, number],
|
|
255
|
+
strength: input.payload.strength,
|
|
256
|
+
}),
|
|
257
|
+
sequence: input.sequence,
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
return ok(undefined);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
patch(patch: Partial<Values>): InstanceResult<void> {
|
|
264
|
+
const candidate: VfxValueMap = { ...this.#values, ...patch };
|
|
265
|
+
const checked = this.contract.validateValues(candidate);
|
|
266
|
+
if (!checked.ok) return checked;
|
|
267
|
+
this.#pendingPatches.push(Object.freeze({ ...patch }));
|
|
268
|
+
return ok(undefined);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
commit(options: VfxInstanceCommitOptions): InstanceResult<VfxInstanceCommit<Values>> {
|
|
272
|
+
const candidate: VfxValueMap = { ...this.#values };
|
|
273
|
+
for (const patch of this.#pendingPatches) Object.assign(candidate, patch);
|
|
274
|
+
const checked = this.contract.validateValues(candidate);
|
|
275
|
+
if (!checked.ok) return checked;
|
|
276
|
+
const nextValues = cloneValues(checked.value);
|
|
277
|
+
const packed = this.contract.pack(nextValues);
|
|
278
|
+
if (!packed.ok) return packed;
|
|
279
|
+
const patchCount = this.#pendingPatches.length;
|
|
280
|
+
const droppedCount = this.#droppedCount;
|
|
281
|
+
const channelInputs = Object.freeze(
|
|
282
|
+
[...this.#pendingChannels]
|
|
283
|
+
.sort((left, right) => left.sequence - right.sequence)
|
|
284
|
+
.map((input) => withTick(input, options.tick)),
|
|
285
|
+
);
|
|
286
|
+
this.#pendingPatches = [];
|
|
287
|
+
this.#pendingChannels = [];
|
|
288
|
+
this.#droppedCount = 0;
|
|
289
|
+
if (patchCount > 0) this.#generation += 1;
|
|
290
|
+
this.#values = nextValues;
|
|
291
|
+
const canonicalPayload = canonicalBytes(this.contract, nextValues);
|
|
292
|
+
const sequence = this.#sequence++;
|
|
293
|
+
const replayInput: VfxReplayInput<Values> = Object.freeze({
|
|
294
|
+
seed: options.seed,
|
|
295
|
+
tick: options.tick,
|
|
296
|
+
generation: this.#generation,
|
|
297
|
+
sequence,
|
|
298
|
+
fingerprint: this.contract.fingerprint,
|
|
299
|
+
payload: cloneBytes(canonicalPayload),
|
|
300
|
+
values: nextValues,
|
|
301
|
+
channelInputs,
|
|
302
|
+
droppedCount,
|
|
303
|
+
});
|
|
304
|
+
return ok({
|
|
305
|
+
seed: options.seed,
|
|
306
|
+
tick: options.tick,
|
|
307
|
+
generation: this.#generation,
|
|
308
|
+
sequence,
|
|
309
|
+
values: nextValues,
|
|
310
|
+
parameterBlock: cloneBytes(packed.value),
|
|
311
|
+
canonicalPayload,
|
|
312
|
+
replayInput,
|
|
313
|
+
patchCount,
|
|
314
|
+
channelInputs,
|
|
315
|
+
droppedCount,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
replay(input: VfxReplayInput<Values>): InstanceResult<VfxInstanceCommit<Values>> {
|
|
320
|
+
if (input.fingerprint !== this.contract.fingerprint) {
|
|
321
|
+
return failure(
|
|
322
|
+
'vfx-instance-replay-mismatch',
|
|
323
|
+
'replay.fingerprint',
|
|
324
|
+
this.contract.fingerprint,
|
|
325
|
+
'replay the input with the matching cooked effect contract',
|
|
326
|
+
input.fingerprint,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
const checked = this.contract.validateValues(input.values);
|
|
330
|
+
if (!checked.ok) return checked;
|
|
331
|
+
const canonicalPayload = canonicalBytes(this.contract, checked.value);
|
|
332
|
+
if (!sameBytes(canonicalPayload, input.payload)) {
|
|
333
|
+
return failure(
|
|
334
|
+
'vfx-instance-replay-mismatch',
|
|
335
|
+
'replay.payload',
|
|
336
|
+
'canonical payload bytes for replay.values',
|
|
337
|
+
'record and replay normalized values from the same fixed-tick input',
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
const packed = this.contract.pack(checked.value);
|
|
341
|
+
if (!packed.ok) return packed;
|
|
342
|
+
this.#values = cloneValues(checked.value);
|
|
343
|
+
this.#generation = input.generation;
|
|
344
|
+
this.#sequence = Math.max(this.#sequence, input.sequence + 1);
|
|
345
|
+
this.#pendingPatches = [];
|
|
346
|
+
this.#pendingChannels = [...input.channelInputs].map((channel) =>
|
|
347
|
+
withTick(channel, input.tick),
|
|
348
|
+
);
|
|
349
|
+
this.#droppedCount = 0;
|
|
350
|
+
const values = this.#values;
|
|
351
|
+
const replayInput: VfxReplayInput<Values> = Object.freeze({
|
|
352
|
+
...input,
|
|
353
|
+
payload: cloneBytes(input.payload),
|
|
354
|
+
values,
|
|
355
|
+
channelInputs: this.#pendingChannels,
|
|
356
|
+
droppedCount: input.droppedCount,
|
|
357
|
+
});
|
|
358
|
+
return ok({
|
|
359
|
+
seed: input.seed,
|
|
360
|
+
tick: input.tick,
|
|
361
|
+
generation: input.generation,
|
|
362
|
+
sequence: input.sequence,
|
|
363
|
+
values,
|
|
364
|
+
parameterBlock: cloneBytes(packed.value),
|
|
365
|
+
canonicalPayload,
|
|
366
|
+
replayInput,
|
|
367
|
+
patchCount: 0,
|
|
368
|
+
channelInputs: this.#pendingChannels,
|
|
369
|
+
droppedCount: input.droppedCount,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function createParticleEffectInstance<Values extends VfxValueMap>(
|
|
375
|
+
contract: VfxEffectContract<Values>,
|
|
376
|
+
options: VfxInstanceOptions<Values> = {},
|
|
377
|
+
): ParticleEffectInstance<Values> {
|
|
378
|
+
return new ParticleEffectInstance(contract, options);
|
|
379
|
+
}
|
package/src/player.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineComponent, type SchemaOf, type ShapeOf } from '@forgeax/engine-ecs';
|
|
2
|
+
import type { VfxValueMap } from './effect-contract.js';
|
|
3
|
+
import type { ParticleEffectInstance } from './instance.js';
|
|
4
|
+
|
|
5
|
+
/** ECS author intent for one shared particle effect. */
|
|
6
|
+
export const ParticleEffectPlayer = defineComponent('ParticleEffectPlayer', {
|
|
7
|
+
// The VFX render owner re-resolves the compiled effect on the target world;
|
|
8
|
+
// playback intent remains portable simulation state.
|
|
9
|
+
effect: { type: 'shared<ParticleEffectAsset>' },
|
|
10
|
+
playing: { type: 'bool', default: true },
|
|
11
|
+
seed: { type: 'u32', default: 0 },
|
|
12
|
+
timeScale: { type: 'f32', default: 1 },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** The four serializable fields stored by {@link ParticleEffectPlayer}. */
|
|
16
|
+
export type ParticleEffectPlayerData = ShapeOf<SchemaOf<typeof ParticleEffectPlayer>>;
|
|
17
|
+
|
|
18
|
+
/** Runtime-owned typed values associated with a player without extending ECS storage. */
|
|
19
|
+
export interface ParticleEffectPlayerBinding<Values extends VfxValueMap = VfxValueMap> {
|
|
20
|
+
readonly instance: ParticleEffectInstance<Values>;
|
|
21
|
+
}
|