@forgeax/engine-vfx 0.1.28 → 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
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { VfxReflectedField, VfxReflectedStruct, VfxValueType } from './effect-contract.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The particle core is a storage-buffer ABI, not a convenient object shape.
|
|
5
|
+
* Keep its fields in one ordered schema so WGSL, host-side allocation and
|
|
6
|
+
* renderer projections all consume the same offsets.
|
|
7
|
+
*/
|
|
8
|
+
export type VfxParticleCoreAttribute =
|
|
9
|
+
| 'position'
|
|
10
|
+
| 'age'
|
|
11
|
+
| 'velocity'
|
|
12
|
+
| 'lifetime'
|
|
13
|
+
| 'color'
|
|
14
|
+
| 'sprite_size'
|
|
15
|
+
| 'sprite_rotation'
|
|
16
|
+
| 'sub_image'
|
|
17
|
+
| 'mesh_orientation'
|
|
18
|
+
| 'mesh_scale'
|
|
19
|
+
| 'material_random'
|
|
20
|
+
| 'id'
|
|
21
|
+
| 'alive';
|
|
22
|
+
|
|
23
|
+
export interface VfxParticleCoreField {
|
|
24
|
+
readonly name: VfxParticleCoreAttribute;
|
|
25
|
+
readonly type: VfxValueType | 'u32';
|
|
26
|
+
readonly offset: number;
|
|
27
|
+
readonly size: number;
|
|
28
|
+
readonly alignment: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface VfxParticleCoreLayout {
|
|
32
|
+
readonly name: 'VfxParticle';
|
|
33
|
+
readonly fields: readonly VfxParticleCoreField[];
|
|
34
|
+
readonly size: number;
|
|
35
|
+
readonly alignment: 16;
|
|
36
|
+
readonly stride: number;
|
|
37
|
+
readonly fingerprint: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const CORE_FIELDS: readonly VfxParticleCoreField[] = Object.freeze([
|
|
41
|
+
{ name: 'position', type: 'vec3<f32>', offset: 0, size: 12, alignment: 16 },
|
|
42
|
+
{ name: 'age', type: 'f32', offset: 12, size: 4, alignment: 4 },
|
|
43
|
+
{ name: 'velocity', type: 'vec3<f32>', offset: 16, size: 12, alignment: 16 },
|
|
44
|
+
{ name: 'lifetime', type: 'f32', offset: 28, size: 4, alignment: 4 },
|
|
45
|
+
{ name: 'color', type: 'vec4<f32>', offset: 32, size: 16, alignment: 16 },
|
|
46
|
+
{ name: 'sprite_size', type: 'vec2<f32>', offset: 48, size: 8, alignment: 8 },
|
|
47
|
+
{ name: 'sprite_rotation', type: 'f32', offset: 56, size: 4, alignment: 4 },
|
|
48
|
+
{ name: 'sub_image', type: 'f32', offset: 60, size: 4, alignment: 4 },
|
|
49
|
+
{ name: 'mesh_orientation', type: 'vec4<f32>', offset: 64, size: 16, alignment: 16 },
|
|
50
|
+
{ name: 'mesh_scale', type: 'vec3<f32>', offset: 80, size: 12, alignment: 16 },
|
|
51
|
+
{ name: 'material_random', type: 'f32', offset: 92, size: 4, alignment: 4 },
|
|
52
|
+
{ name: 'id', type: 'u32', offset: 96, size: 4, alignment: 4 },
|
|
53
|
+
{ name: 'alive', type: 'u32', offset: 100, size: 4, alignment: 4 },
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
/** Program v3 core layout. The 112-byte stride includes WGSL's final 16-byte alignment. */
|
|
57
|
+
export const VFX_PARTICLE_CORE_LAYOUT: VfxParticleCoreLayout = Object.freeze({
|
|
58
|
+
name: 'VfxParticle',
|
|
59
|
+
fields: CORE_FIELDS,
|
|
60
|
+
size: 112,
|
|
61
|
+
alignment: 16,
|
|
62
|
+
stride: 112,
|
|
63
|
+
fingerprint: 'sha256:9e3c1566f6d0600cedb94bd3c4b76c4e7c456fd563b37065220d4e33cf83c777',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export const VFX_PARTICLE_CORE_STRIDE = VFX_PARTICLE_CORE_LAYOUT.stride;
|
|
67
|
+
|
|
68
|
+
const CORE_FIELD_BY_NAME = new Map(CORE_FIELDS.map((field) => [field.name, field]));
|
|
69
|
+
|
|
70
|
+
export function vfxParticleCoreField(name: VfxParticleCoreAttribute): VfxParticleCoreField {
|
|
71
|
+
// The table is closed above; this branch makes an accidental edit fail loudly in development.
|
|
72
|
+
const field = CORE_FIELD_BY_NAME.get(name);
|
|
73
|
+
if (field === undefined) throw new Error(`unknown VFX particle core field ${name}`);
|
|
74
|
+
return field;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** WGSL declaration generated from the core schema. */
|
|
78
|
+
export function vfxParticleCoreWgsl(): string {
|
|
79
|
+
return [
|
|
80
|
+
'struct VfxParticle {',
|
|
81
|
+
...CORE_FIELDS.map((field) => ` ${field.name}: ${field.type},`),
|
|
82
|
+
'}',
|
|
83
|
+
].join('\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Effect-level data is intentionally separate from per-particle custom data. */
|
|
87
|
+
export interface VfxParametersLayout extends VfxReflectedStruct {
|
|
88
|
+
readonly name: 'VfxParameters';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface VfxCustomLayout extends VfxReflectedStruct {
|
|
92
|
+
readonly name: 'VfxCustom';
|
|
93
|
+
/** Array stride for `array<VfxCustom>` in the persistent GPU storage buffer. */
|
|
94
|
+
readonly stride: number;
|
|
95
|
+
/** Number of vec4-equivalent lanes consumed by the custom record. */
|
|
96
|
+
readonly lanes: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const VALUE_LAYOUT: Readonly<
|
|
100
|
+
Record<VfxValueType, { readonly alignment: number; readonly size: number }>
|
|
101
|
+
> = {
|
|
102
|
+
f32: { alignment: 4, size: 4 },
|
|
103
|
+
i32: { alignment: 4, size: 4 },
|
|
104
|
+
u32: { alignment: 4, size: 4 },
|
|
105
|
+
'vec2<f32>': { alignment: 8, size: 8 },
|
|
106
|
+
'vec3<f32>': { alignment: 16, size: 12 },
|
|
107
|
+
'vec4<f32>': { alignment: 16, size: 16 },
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
function alignUp(value: number, alignment: number): number {
|
|
111
|
+
return Math.ceil(value / alignment) * alignment;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Re-derive custom offsets from reflected fields and reject the bounded renderer budget. */
|
|
115
|
+
export function deriveVfxCustomLayout(struct: VfxReflectedStruct, maxLanes = 4): VfxCustomLayout {
|
|
116
|
+
if (struct.name !== 'VfxCustom') {
|
|
117
|
+
throw new TypeError(`expected VfxCustom reflection, received ${struct.name}`);
|
|
118
|
+
}
|
|
119
|
+
const fields: VfxReflectedField[] = [];
|
|
120
|
+
let offset = 0;
|
|
121
|
+
let alignment = 1;
|
|
122
|
+
for (const field of struct.fields) {
|
|
123
|
+
const layout = VALUE_LAYOUT[field.type];
|
|
124
|
+
offset = alignUp(offset, layout.alignment);
|
|
125
|
+
fields.push({ ...field, offset, size: layout.size, alignment: layout.alignment });
|
|
126
|
+
offset += layout.size;
|
|
127
|
+
alignment = Math.max(alignment, layout.alignment);
|
|
128
|
+
}
|
|
129
|
+
const size = fields.length === 0 ? 0 : alignUp(offset, alignment);
|
|
130
|
+
// Storage arrays use the WGSL struct alignment, not uniform/vec4 lane
|
|
131
|
+
// alignment. Lanes are a budget unit only, never an alternate GPU ABI.
|
|
132
|
+
const stride = size;
|
|
133
|
+
const lanes = Math.ceil(stride / 16);
|
|
134
|
+
if (lanes > maxLanes) {
|
|
135
|
+
throw new RangeError(`VfxCustom consumes ${lanes} vec4 lanes; maximum is ${maxLanes}`);
|
|
136
|
+
}
|
|
137
|
+
return Object.freeze({
|
|
138
|
+
name: 'VfxCustom',
|
|
139
|
+
fields: Object.freeze(fields),
|
|
140
|
+
size,
|
|
141
|
+
alignment,
|
|
142
|
+
stride,
|
|
143
|
+
lanes,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type VfxParticleCoreValue =
|
|
148
|
+
| readonly [number, number, number]
|
|
149
|
+
| readonly [number, number, number, number]
|
|
150
|
+
| readonly [number, number]
|
|
151
|
+
| number;
|
|
152
|
+
|
|
153
|
+
export interface VfxParticleCoreValues {
|
|
154
|
+
readonly position: readonly [number, number, number];
|
|
155
|
+
readonly age: number;
|
|
156
|
+
readonly velocity: readonly [number, number, number];
|
|
157
|
+
readonly lifetime: number;
|
|
158
|
+
readonly color: readonly [number, number, number, number];
|
|
159
|
+
readonly sprite_size: readonly [number, number];
|
|
160
|
+
readonly sprite_rotation: number;
|
|
161
|
+
readonly sub_image: number;
|
|
162
|
+
readonly mesh_orientation: readonly [number, number, number, number];
|
|
163
|
+
readonly mesh_scale: readonly [number, number, number];
|
|
164
|
+
readonly material_random: number;
|
|
165
|
+
readonly id: number;
|
|
166
|
+
readonly alive: number;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function finite(value: number): boolean {
|
|
170
|
+
return Number.isFinite(value);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Pack a core record using schema offsets. This is for inspection/replay fixtures, not a particle mirror. */
|
|
174
|
+
export function encodeVfxParticleCore(value: VfxParticleCoreValues): Uint8Array {
|
|
175
|
+
const bytes = new Uint8Array(VFX_PARTICLE_CORE_STRIDE);
|
|
176
|
+
const view = new DataView(bytes.buffer);
|
|
177
|
+
const write = (name: VfxParticleCoreAttribute, values: readonly number[]): void => {
|
|
178
|
+
const field = vfxParticleCoreField(name);
|
|
179
|
+
for (const component of values) {
|
|
180
|
+
if (!finite(component)) throw new TypeError(`non-finite VFX particle field ${name}`);
|
|
181
|
+
if (
|
|
182
|
+
field.type === 'u32' &&
|
|
183
|
+
(!Number.isInteger(component) || component < 0 || component > 0xffffffff)
|
|
184
|
+
) {
|
|
185
|
+
throw new RangeError(`u32 VFX particle field ${name} is outside its valid range`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
189
|
+
const component = values[index] ?? 0;
|
|
190
|
+
if (field.type === 'u32') view.setUint32(field.offset + index * 4, component, true);
|
|
191
|
+
else view.setFloat32(field.offset + index * 4, component, true);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
write('position', value.position);
|
|
195
|
+
write('age', [value.age]);
|
|
196
|
+
write('velocity', value.velocity);
|
|
197
|
+
write('lifetime', [value.lifetime]);
|
|
198
|
+
write('color', value.color);
|
|
199
|
+
write('sprite_size', value.sprite_size);
|
|
200
|
+
write('sprite_rotation', [value.sprite_rotation]);
|
|
201
|
+
write('sub_image', [value.sub_image]);
|
|
202
|
+
write('mesh_orientation', value.mesh_orientation);
|
|
203
|
+
write('mesh_scale', value.mesh_scale);
|
|
204
|
+
write('material_random', [value.material_random]);
|
|
205
|
+
write('id', [value.id]);
|
|
206
|
+
write('alive', [value.alive]);
|
|
207
|
+
return bytes;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Return the normalized age used by all renderer semantic projections. */
|
|
211
|
+
export function normalizedVfxParticleAge(age: number, lifetime: number): number {
|
|
212
|
+
if (!finite(age) || !finite(lifetime) || lifetime <= 0) return 0;
|
|
213
|
+
return Math.min(1, Math.max(0, age / lifetime));
|
|
214
|
+
}
|