@forgeax/engine-vfx-render 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 +163 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/billboard-advanced.integration.test.d.ts +2 -0
- package/dist/__tests__/billboard-advanced.integration.test.d.ts.map +1 -0
- package/dist/__tests__/data-interface-providers.unit.test.d.ts +2 -0
- package/dist/__tests__/data-interface-providers.unit.test.d.ts.map +1 -0
- package/dist/__tests__/data-interface-public-api.test-d.d.ts +2 -0
- package/dist/__tests__/data-interface-public-api.test-d.d.ts.map +1 -0
- package/dist/__tests__/event-resources.unit.test.d.ts +2 -0
- package/dist/__tests__/event-resources.unit.test.d.ts.map +1 -0
- package/dist/__tests__/fog-producer.integration.test.d.ts +2 -0
- package/dist/__tests__/fog-producer.integration.test.d.ts.map +1 -0
- package/dist/__tests__/gpu-host.integration.test.d.ts +2 -0
- package/dist/__tests__/gpu-host.integration.test.d.ts.map +1 -0
- package/dist/__tests__/inspect-snapshot.integration.test.d.ts +2 -0
- package/dist/__tests__/inspect-snapshot.integration.test.d.ts.map +1 -0
- package/dist/__tests__/particle-resources.unit.test.d.ts +2 -0
- package/dist/__tests__/particle-resources.unit.test.d.ts.map +1 -0
- package/dist/__tests__/render-vocabulary-owner.test-d.d.ts +2 -0
- package/dist/__tests__/render-vocabulary-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/stage-plan.integration.test.d.ts +2 -0
- package/dist/__tests__/stage-plan.integration.test.d.ts.map +1 -0
- package/dist/__tests__/stage-recovery.integration.test.d.ts +2 -0
- package/dist/__tests__/stage-recovery.integration.test.d.ts.map +1 -0
- package/dist/__tests__/stage-resource-access-owner.test-d.d.ts +2 -0
- package/dist/__tests__/stage-resource-access-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/stage-resource-layout.unit.test.d.ts +2 -0
- package/dist/__tests__/stage-resource-layout.unit.test.d.ts.map +1 -0
- package/dist/__tests__/topology-capacity.unit.test.d.ts +2 -0
- package/dist/__tests__/topology-capacity.unit.test.d.ts.map +1 -0
- package/dist/__tests__/topology-resources.unit.test.d.ts +2 -0
- package/dist/__tests__/topology-resources.unit.test.d.ts.map +1 -0
- package/dist/feature/camera.d.ts +11 -0
- package/dist/feature/camera.d.ts.map +1 -0
- package/dist/feature/event-resources.d.ts +9 -0
- package/dist/feature/event-resources.d.ts.map +1 -0
- package/dist/feature/gpu-particle-feature.d.ts +96 -0
- package/dist/feature/gpu-particle-feature.d.ts.map +1 -0
- package/dist/feature/particle-resources.d.ts +66 -0
- package/dist/feature/particle-resources.d.ts.map +1 -0
- package/dist/feature/stage-plan.d.ts +54 -0
- package/dist/feature/stage-plan.d.ts.map +1 -0
- package/dist/feature/topologies/beam.d.ts +16 -0
- package/dist/feature/topologies/beam.d.ts.map +1 -0
- package/dist/feature/topologies/ribbon.d.ts +15 -0
- package/dist/feature/topologies/ribbon.d.ts.map +1 -0
- package/dist/feature/topologies/trail.d.ts +19 -0
- package/dist/feature/topologies/trail.d.ts.map +1 -0
- package/dist/host/data-interface-providers.d.ts +19 -0
- package/dist/host/data-interface-providers.d.ts.map +1 -0
- package/dist/host/vfx-runtime-host.d.ts +87 -0
- package/dist/host/vfx-runtime-host.d.ts.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1572 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +63 -0
- package/src/__tests__/billboard-advanced.integration.test.ts +69 -0
- package/src/__tests__/data-interface-providers.unit.test.ts +55 -0
- package/src/__tests__/data-interface-public-api.test-d.ts +58 -0
- package/src/__tests__/event-resources.unit.test.ts +71 -0
- package/src/__tests__/fog-producer.integration.test.ts +59 -0
- package/src/__tests__/gpu-host.integration.test.ts +441 -0
- package/src/__tests__/inspect-snapshot.integration.test.ts +26 -0
- package/src/__tests__/particle-resources.unit.test.ts +75 -0
- package/src/__tests__/render-vocabulary-owner.test-d.ts +81 -0
- package/src/__tests__/stage-plan.integration.test.ts +16 -0
- package/src/__tests__/stage-recovery.integration.test.ts +24 -0
- package/src/__tests__/stage-resource-access-owner.test-d.ts +26 -0
- package/src/__tests__/stage-resource-layout.unit.test.ts +40 -0
- package/src/__tests__/topology-capacity.unit.test.ts +59 -0
- package/src/__tests__/topology-resources.unit.test.ts +29 -0
- package/src/feature/camera.ts +12 -0
- package/src/feature/event-resources.ts +62 -0
- package/src/feature/gpu-particle-feature.ts +1225 -0
- package/src/feature/particle-resources.ts +203 -0
- package/src/feature/stage-plan.ts +282 -0
- package/src/feature/topologies/beam.ts +41 -0
- package/src/feature/topologies/ribbon.ts +31 -0
- package/src/feature/topologies/trail.ts +43 -0
- package/src/host/data-interface-providers.ts +119 -0
- package/src/host/vfx-runtime-host.ts +402 -0
- package/src/index.ts +57 -0
- package/src/shaders/beam.wgsl +69 -0
- package/src/shaders/billboard.wgsl +132 -0
- package/src/shaders/event.wgsl +11 -0
- package/src/shaders/mesh.wgsl +98 -0
- package/src/shaders/ribbon.wgsl +69 -0
- package/src/shaders/stage.wgsl +5 -0
- package/src/shaders/trail.wgsl +77 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { RenderFeatureMaterialShaderBindingContract } from '@forgeax/engine-render';
|
|
2
|
+
import {
|
|
3
|
+
err,
|
|
4
|
+
type MaterialAsset,
|
|
5
|
+
type MaterialRenderState,
|
|
6
|
+
type MeshAsset,
|
|
7
|
+
ok,
|
|
8
|
+
type Result,
|
|
9
|
+
} from '@forgeax/engine-types';
|
|
10
|
+
import type { ParticleRendererSource } from '@forgeax/engine-vfx';
|
|
11
|
+
|
|
12
|
+
type ParticleRendererKind = ParticleRendererSource['kind'];
|
|
13
|
+
type ParticleTopologyRenderer = Extract<ParticleRendererSource, { readonly capacity: number }>;
|
|
14
|
+
type ParticleTopologyKind = ParticleTopologyRenderer['kind'];
|
|
15
|
+
|
|
16
|
+
export const PARTICLE_SHADER_IDENTIFIERS = Object.freeze({
|
|
17
|
+
billboard: 'forgeax::vfx-render.particles.billboard',
|
|
18
|
+
mesh: 'forgeax::vfx-render.particles.mesh',
|
|
19
|
+
ribbon: 'forgeax::vfx-render.particles.ribbon',
|
|
20
|
+
trail: 'forgeax::vfx-render.particles.trail',
|
|
21
|
+
beam: 'forgeax::vfx-render.particles.beam',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export interface TopologyResourcePlan {
|
|
25
|
+
readonly topology: ParticleTopologyKind;
|
|
26
|
+
readonly capacity: number;
|
|
27
|
+
readonly vertexBytes: number;
|
|
28
|
+
readonly indexBytes: number;
|
|
29
|
+
readonly indirectBytes: number;
|
|
30
|
+
readonly resourceKey: string;
|
|
31
|
+
readonly historyLength?: number;
|
|
32
|
+
readonly stripKey?: 'alive-index';
|
|
33
|
+
readonly endpointField?: 'velocity';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TopologyResourceError {
|
|
37
|
+
readonly code: 'vfx-topology-resource-invalid';
|
|
38
|
+
readonly expected: string;
|
|
39
|
+
readonly hint: string;
|
|
40
|
+
readonly detail: { readonly path: string };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createTopologyResourcePlan(
|
|
44
|
+
renderer: unknown,
|
|
45
|
+
): Result<TopologyResourcePlan, TopologyResourceError> {
|
|
46
|
+
if (renderer === null || typeof renderer !== 'object' || Array.isArray(renderer))
|
|
47
|
+
return err({
|
|
48
|
+
code: 'vfx-topology-resource-invalid',
|
|
49
|
+
expected: 'a topology renderer object',
|
|
50
|
+
hint: 'declare a ribbon, trail, or beam renderer',
|
|
51
|
+
detail: { path: 'renderer' },
|
|
52
|
+
});
|
|
53
|
+
const value = renderer as Partial<ParticleRendererSource> & Record<string, unknown>;
|
|
54
|
+
if (value.kind !== 'ribbon' && value.kind !== 'trail' && value.kind !== 'beam')
|
|
55
|
+
return err({
|
|
56
|
+
code: 'vfx-topology-resource-invalid',
|
|
57
|
+
expected: 'ribbon, trail, or beam',
|
|
58
|
+
hint: 'do not alias topology output to billboard or mesh',
|
|
59
|
+
detail: { path: 'renderer.kind' },
|
|
60
|
+
});
|
|
61
|
+
if (
|
|
62
|
+
typeof value.capacity !== 'number' ||
|
|
63
|
+
!Number.isInteger(value.capacity) ||
|
|
64
|
+
value.capacity <= 0 ||
|
|
65
|
+
value.capacity > 65536
|
|
66
|
+
)
|
|
67
|
+
return err({
|
|
68
|
+
code: 'vfx-topology-resource-invalid',
|
|
69
|
+
expected: 'capacity in the range 1..65536',
|
|
70
|
+
hint: 'bound topology resources before allocating them',
|
|
71
|
+
detail: { path: 'renderer.capacity' },
|
|
72
|
+
});
|
|
73
|
+
const capacity = value.capacity;
|
|
74
|
+
if (value.kind === 'ribbon' && value.stripKey !== 'alive-index')
|
|
75
|
+
return err({
|
|
76
|
+
code: 'vfx-topology-resource-invalid',
|
|
77
|
+
expected: "stripKey 'alive-index'",
|
|
78
|
+
hint: 'use the managed alive-list order until a custom WGSL topology stage owns grouping',
|
|
79
|
+
detail: { path: 'renderer.stripKey' },
|
|
80
|
+
});
|
|
81
|
+
if (
|
|
82
|
+
value.kind === 'trail' &&
|
|
83
|
+
(typeof value.historyLength !== 'number' ||
|
|
84
|
+
!Number.isInteger(value.historyLength) ||
|
|
85
|
+
value.historyLength <= 0 ||
|
|
86
|
+
value.historyLength > 256)
|
|
87
|
+
)
|
|
88
|
+
return err({
|
|
89
|
+
code: 'vfx-topology-resource-invalid',
|
|
90
|
+
expected: 'historyLength in the range 1..256',
|
|
91
|
+
hint: 'bound trail history storage',
|
|
92
|
+
detail: { path: 'renderer.historyLength' },
|
|
93
|
+
});
|
|
94
|
+
if (value.kind === 'beam' && value.endpointField !== 'velocity')
|
|
95
|
+
return err({
|
|
96
|
+
code: 'vfx-topology-resource-invalid',
|
|
97
|
+
expected: "endpointField 'velocity'",
|
|
98
|
+
hint: 'use the managed velocity endpoint until a custom WGSL topology stage owns endpoints',
|
|
99
|
+
detail: { path: 'renderer.endpointField' },
|
|
100
|
+
});
|
|
101
|
+
const vertexStride = 12 * 4;
|
|
102
|
+
const segments =
|
|
103
|
+
value.kind === 'trail' ? capacity * Math.max(1, (value.historyLength as number) - 1) : capacity;
|
|
104
|
+
return ok({
|
|
105
|
+
topology: value.kind,
|
|
106
|
+
capacity,
|
|
107
|
+
vertexBytes: Math.max(vertexStride, segments * vertexStride),
|
|
108
|
+
indexBytes: 0,
|
|
109
|
+
indirectBytes: 20,
|
|
110
|
+
resourceKey: `vfx-topology-${value.kind}`,
|
|
111
|
+
...(value.kind === 'ribbon' ? { stripKey: 'alive-index' as const } : {}),
|
|
112
|
+
...(value.kind === 'trail' ? { historyLength: value.historyLength as number } : {}),
|
|
113
|
+
...(value.kind === 'beam' ? { endpointField: 'velocity' as const } : {}),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface TopologyCapacityInput {
|
|
118
|
+
readonly requested: number;
|
|
119
|
+
readonly produced: number;
|
|
120
|
+
readonly degenerate?: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function topologyCapacitySnapshot(plan: TopologyResourcePlan, input: TopologyCapacityInput) {
|
|
124
|
+
const produced = Math.max(0, Math.min(plan.capacity, input.produced));
|
|
125
|
+
const requested = Math.max(0, input.requested);
|
|
126
|
+
return {
|
|
127
|
+
topology: plan.topology,
|
|
128
|
+
capacity: plan.capacity,
|
|
129
|
+
produced,
|
|
130
|
+
dropped: Math.max(0, requested - produced),
|
|
131
|
+
overflow: Math.max(0, requested - plan.capacity),
|
|
132
|
+
degenerate: Math.max(0, input.degenerate ?? 0),
|
|
133
|
+
} as const;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface ParticleMaterialPass {
|
|
137
|
+
readonly shader: string;
|
|
138
|
+
readonly renderState?: MaterialRenderState;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Resolve the one material pass that is meaningful for a particle projection.
|
|
143
|
+
* A regular Forward pass is intentionally ignored: VFX vertex layouts are a
|
|
144
|
+
* different contract, so silently accepting it would compile the wrong input
|
|
145
|
+
* shape and make authored particle shaders appear to work while never running.
|
|
146
|
+
*/
|
|
147
|
+
export function particleMaterialPass(
|
|
148
|
+
kind: ParticleRendererKind,
|
|
149
|
+
material: MaterialAsset | undefined,
|
|
150
|
+
): ParticleMaterialPass {
|
|
151
|
+
const pass = material?.passes?.find((candidate) => candidate.name === `particle-${kind}`);
|
|
152
|
+
return {
|
|
153
|
+
shader: pass?.program.module ?? PARTICLE_SHADER_IDENTIFIERS[kind],
|
|
154
|
+
...(pass?.renderState === undefined
|
|
155
|
+
? {}
|
|
156
|
+
: { renderState: pass.renderState as MaterialRenderState }),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Scene-depth is a shader binding contract, not a renderer-kind convention. */
|
|
161
|
+
export function particleMaterialUsesSceneDepth(
|
|
162
|
+
contract: RenderFeatureMaterialShaderBindingContract | 'view-with-resource',
|
|
163
|
+
): boolean {
|
|
164
|
+
return contract === 'group-0-resource' || contract === 'view-with-resource';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** True when the authored particle shader consumes the standard material bind group. */
|
|
168
|
+
export function particleMaterialUsesBindings(material: MaterialAsset | undefined): boolean {
|
|
169
|
+
return (material?.parameters?.length ?? 0) > 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function floatAttribute(value: ArrayBuffer | Float32Array | Uint16Array | undefined): Float32Array {
|
|
173
|
+
if (value instanceof Float32Array) return value;
|
|
174
|
+
if (value instanceof Uint16Array) return Float32Array.from(value);
|
|
175
|
+
return value === undefined ? new Float32Array() : new Float32Array(value);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function canonicalMeshVertices(mesh: MeshAsset): Float32Array {
|
|
179
|
+
if (mesh.vertices.length > 0 && mesh.vertices.length % 12 === 0) return mesh.vertices;
|
|
180
|
+
const positions = floatAttribute(mesh.attributes.position);
|
|
181
|
+
const vertexCount = Math.floor(positions.length / 3);
|
|
182
|
+
const normals = floatAttribute(mesh.attributes.normal);
|
|
183
|
+
const uvs = floatAttribute(mesh.attributes.uv);
|
|
184
|
+
const tangents = floatAttribute(mesh.attributes.tangent);
|
|
185
|
+
const result = new Float32Array(vertexCount * 12);
|
|
186
|
+
for (let index = 0; index < vertexCount; index += 1) {
|
|
187
|
+
const target = index * 12;
|
|
188
|
+
result.set(positions.subarray(index * 3, index * 3 + 3), target);
|
|
189
|
+
result.set(
|
|
190
|
+
normals.length >= index * 3 + 3 ? normals.subarray(index * 3, index * 3 + 3) : [0, 0, 1],
|
|
191
|
+
target + 3,
|
|
192
|
+
);
|
|
193
|
+
result.set(
|
|
194
|
+
uvs.length >= index * 2 + 2 ? uvs.subarray(index * 2, index * 2 + 2) : [0, 0],
|
|
195
|
+
target + 6,
|
|
196
|
+
);
|
|
197
|
+
result.set(
|
|
198
|
+
tangents.length >= index * 4 + 4 ? tangents.subarray(index * 4, index * 4 + 4) : [1, 0, 0, 1],
|
|
199
|
+
target + 8,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import type { RenderFeatureGpuBindingsRef, RenderFeatureGpuDispatch } from '@forgeax/engine-render';
|
|
2
|
+
import type { VfxGpuStageReflection } from '@forgeax/engine-vfx';
|
|
3
|
+
|
|
4
|
+
const MANAGED_STAGES = new Set(['spawn', 'update', 'scan', 'compact']);
|
|
5
|
+
const RESOURCE_NAMES = new Set([
|
|
6
|
+
'particles',
|
|
7
|
+
'runtime',
|
|
8
|
+
'aliveIndices',
|
|
9
|
+
'counters',
|
|
10
|
+
'indirect',
|
|
11
|
+
'scratch',
|
|
12
|
+
'billboardInstances',
|
|
13
|
+
'channelInputs',
|
|
14
|
+
'events',
|
|
15
|
+
'eventCounters',
|
|
16
|
+
]);
|
|
17
|
+
const STAGE_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
18
|
+
const ENTRY_POINT = /^forgeax_vfx_stage_[a-z][a-z0-9-]{0,31}_main$/;
|
|
19
|
+
const MAX_ITERATIONS = 64;
|
|
20
|
+
|
|
21
|
+
export interface VfxValidatedStage {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly entry: string;
|
|
24
|
+
readonly entryPoint: string;
|
|
25
|
+
readonly domain: 'particle';
|
|
26
|
+
readonly resources: readonly {
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly access: VfxGpuStageReflection['resources'][number]['access'];
|
|
29
|
+
}[];
|
|
30
|
+
readonly dependsOn: readonly string[];
|
|
31
|
+
readonly iterationBudget: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface VfxStageReadiness {
|
|
35
|
+
readonly id: string;
|
|
36
|
+
readonly state: 'ready' | 'candidate-rejected' | 'stale' | 'rebuilding';
|
|
37
|
+
readonly generation: number;
|
|
38
|
+
readonly lastKnownGoodGeneration?: number;
|
|
39
|
+
readonly retryable: boolean;
|
|
40
|
+
readonly error?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface VfxStagePlanObservation {
|
|
44
|
+
readonly validatedStagePlan: VfxValidatedStagePlan;
|
|
45
|
+
readonly stageReadiness: readonly VfxStageReadiness[];
|
|
46
|
+
readonly stageOutput: 'active' | 'last-known-good' | 'empty';
|
|
47
|
+
readonly lastKnownGoodStage:
|
|
48
|
+
| { readonly fingerprint: string; readonly generation: number }
|
|
49
|
+
| undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface VfxValidatedStagePlan {
|
|
53
|
+
readonly stages: readonly VfxValidatedStage[];
|
|
54
|
+
readonly fingerprint: string;
|
|
55
|
+
readonly generation: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface VfxStagePlanError {
|
|
59
|
+
readonly code:
|
|
60
|
+
| 'stage-id-invalid'
|
|
61
|
+
| 'stage-entry-point-invalid'
|
|
62
|
+
| 'stage-domain-invalid'
|
|
63
|
+
| 'stage-resource-invalid'
|
|
64
|
+
| 'stage-dependency-invalid'
|
|
65
|
+
| 'stage-cycle'
|
|
66
|
+
| 'stage-budget-invalid';
|
|
67
|
+
readonly stageId: string;
|
|
68
|
+
readonly hint: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type VfxStageRecovery = 'device-loss' | 'stale';
|
|
72
|
+
|
|
73
|
+
function failure(
|
|
74
|
+
code: VfxStagePlanError['code'],
|
|
75
|
+
stageId: string,
|
|
76
|
+
hint: string,
|
|
77
|
+
): { ok: false; error: VfxStagePlanError } {
|
|
78
|
+
return { ok: false, error: { code, stageId, hint } };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function stableStage(stage: VfxGpuStageReflection): VfxValidatedStage {
|
|
82
|
+
return {
|
|
83
|
+
id: stage.id,
|
|
84
|
+
entry: stage.entry,
|
|
85
|
+
entryPoint: stage.entryPoint,
|
|
86
|
+
domain: stage.domain,
|
|
87
|
+
resources: Object.freeze(
|
|
88
|
+
stage.resources.map((resource) => ({ name: resource.name, access: resource.access })),
|
|
89
|
+
),
|
|
90
|
+
dependsOn: Object.freeze([...stage.dependsOn]),
|
|
91
|
+
iterationBudget: stage.iterationBudget,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Validate the compiler reflection before a RenderFeature can contribute it. */
|
|
96
|
+
export function validatedStagePlan(
|
|
97
|
+
reflection: VfxGpuStageReflection[] | readonly VfxGpuStageReflection[] | undefined,
|
|
98
|
+
generation: number,
|
|
99
|
+
):
|
|
100
|
+
| { readonly ok: true; readonly value: VfxValidatedStagePlan }
|
|
101
|
+
| { readonly ok: false; readonly error: VfxStagePlanError } {
|
|
102
|
+
const source = reflection ?? [];
|
|
103
|
+
const ids = new Set<string>();
|
|
104
|
+
const stages: VfxValidatedStage[] = [];
|
|
105
|
+
for (const stage of source) {
|
|
106
|
+
if (!STAGE_ID.test(stage.id) || ids.has(stage.id)) {
|
|
107
|
+
return failure(
|
|
108
|
+
'stage-id-invalid',
|
|
109
|
+
stage.id,
|
|
110
|
+
'recook the effect with unique bounded stage ids',
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
ids.add(stage.id);
|
|
114
|
+
if (stage.domain !== 'particle' || MANAGED_STAGES.has(stage.domain)) {
|
|
115
|
+
return failure('stage-domain-invalid', stage.id, 'use the managed particle dispatch domain');
|
|
116
|
+
}
|
|
117
|
+
if (!ENTRY_POINT.test(stage.entryPoint)) {
|
|
118
|
+
return failure(
|
|
119
|
+
'stage-entry-point-invalid',
|
|
120
|
+
stage.id,
|
|
121
|
+
'use the compiler-generated stage entry point',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (
|
|
125
|
+
!Number.isInteger(stage.iterationBudget) ||
|
|
126
|
+
stage.iterationBudget < 1 ||
|
|
127
|
+
stage.iterationBudget > MAX_ITERATIONS
|
|
128
|
+
) {
|
|
129
|
+
return failure(
|
|
130
|
+
'stage-budget-invalid',
|
|
131
|
+
stage.id,
|
|
132
|
+
'use an integer iteration budget from 1 through 64',
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
const resources = new Set<string>();
|
|
136
|
+
for (const resource of stage.resources) {
|
|
137
|
+
if (
|
|
138
|
+
!RESOURCE_NAMES.has(resource.name) ||
|
|
139
|
+
!['read', 'write', 'read-write'].includes(resource.access)
|
|
140
|
+
) {
|
|
141
|
+
return failure(
|
|
142
|
+
'stage-resource-invalid',
|
|
143
|
+
stage.id,
|
|
144
|
+
'declare only managed VFX resources with a supported access mode',
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (resources.has(resource.name)) {
|
|
148
|
+
return failure(
|
|
149
|
+
'stage-resource-invalid',
|
|
150
|
+
stage.id,
|
|
151
|
+
'declare each managed VFX resource once',
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
resources.add(resource.name);
|
|
155
|
+
}
|
|
156
|
+
stages.push(stableStage(stage));
|
|
157
|
+
}
|
|
158
|
+
const byId = new Map(stages.map((stage) => [stage.id, stage]));
|
|
159
|
+
const visiting = new Set<string>();
|
|
160
|
+
const visited = new Set<string>();
|
|
161
|
+
const ordered: VfxValidatedStage[] = [];
|
|
162
|
+
const visit = (stage: VfxValidatedStage): VfxStagePlanError | undefined => {
|
|
163
|
+
if (visited.has(stage.id)) return undefined;
|
|
164
|
+
if (visiting.has(stage.id))
|
|
165
|
+
return {
|
|
166
|
+
code: 'stage-cycle',
|
|
167
|
+
stageId: stage.id,
|
|
168
|
+
hint: 'remove the stage dependency cycle and recook the effect',
|
|
169
|
+
};
|
|
170
|
+
visiting.add(stage.id);
|
|
171
|
+
for (const dependency of stage.dependsOn) {
|
|
172
|
+
if (MANAGED_STAGES.has(dependency)) continue;
|
|
173
|
+
const dependencyStage = byId.get(dependency);
|
|
174
|
+
if (dependencyStage === undefined) {
|
|
175
|
+
return {
|
|
176
|
+
code: 'stage-dependency-invalid',
|
|
177
|
+
stageId: stage.id,
|
|
178
|
+
hint: 'declare every stage dependency or use a managed stage',
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const error = visit(dependencyStage);
|
|
182
|
+
if (error !== undefined) return error;
|
|
183
|
+
}
|
|
184
|
+
visiting.delete(stage.id);
|
|
185
|
+
visited.add(stage.id);
|
|
186
|
+
ordered.push(stage);
|
|
187
|
+
return undefined;
|
|
188
|
+
};
|
|
189
|
+
for (const stage of stages) {
|
|
190
|
+
const error = visit(stage);
|
|
191
|
+
if (error !== undefined) return { ok: false, error };
|
|
192
|
+
}
|
|
193
|
+
const normalized = Object.freeze(ordered);
|
|
194
|
+
return {
|
|
195
|
+
ok: true,
|
|
196
|
+
value: Object.freeze({
|
|
197
|
+
stages: normalized,
|
|
198
|
+
fingerprint: JSON.stringify(normalized),
|
|
199
|
+
generation,
|
|
200
|
+
}),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function stageDispatches(
|
|
205
|
+
plan: VfxValidatedStagePlan,
|
|
206
|
+
workgroups: number,
|
|
207
|
+
bindings: RenderFeatureGpuBindingsRef,
|
|
208
|
+
): readonly RenderFeatureGpuDispatch[] {
|
|
209
|
+
return plan.stages.map((stage) => ({
|
|
210
|
+
entryPoint: stage.entryPoint,
|
|
211
|
+
workgroups: [workgroups] as const,
|
|
212
|
+
bindings,
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function observeStagePlan(
|
|
217
|
+
candidate: ReturnType<typeof validatedStagePlan>,
|
|
218
|
+
generation: number,
|
|
219
|
+
lastKnownGoodStage: VfxValidatedStagePlan | undefined,
|
|
220
|
+
): VfxStagePlanObservation {
|
|
221
|
+
if (candidate.ok) {
|
|
222
|
+
return {
|
|
223
|
+
validatedStagePlan: candidate.value,
|
|
224
|
+
stageReadiness: candidate.value.stages.map((stage) => ({
|
|
225
|
+
id: stage.id,
|
|
226
|
+
state: 'ready' as const,
|
|
227
|
+
generation,
|
|
228
|
+
lastKnownGoodGeneration: generation,
|
|
229
|
+
retryable: false,
|
|
230
|
+
})),
|
|
231
|
+
stageOutput: candidate.value.stages.length === 0 ? 'empty' : 'active',
|
|
232
|
+
lastKnownGoodStage:
|
|
233
|
+
candidate.value.stages.length === 0
|
|
234
|
+
? lastKnownGoodStage === undefined
|
|
235
|
+
? undefined
|
|
236
|
+
: {
|
|
237
|
+
fingerprint: lastKnownGoodStage.fingerprint,
|
|
238
|
+
generation: lastKnownGoodStage.generation,
|
|
239
|
+
}
|
|
240
|
+
: { fingerprint: candidate.value.fingerprint, generation },
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const retained = lastKnownGoodStage ?? { stages: [], fingerprint: '', generation };
|
|
244
|
+
return {
|
|
245
|
+
validatedStagePlan: retained,
|
|
246
|
+
stageReadiness: [
|
|
247
|
+
{
|
|
248
|
+
id: candidate.error.stageId,
|
|
249
|
+
state: 'candidate-rejected',
|
|
250
|
+
generation,
|
|
251
|
+
...(lastKnownGoodStage === undefined
|
|
252
|
+
? {}
|
|
253
|
+
: { lastKnownGoodGeneration: lastKnownGoodStage.generation }),
|
|
254
|
+
retryable: true,
|
|
255
|
+
error: candidate.error.code,
|
|
256
|
+
},
|
|
257
|
+
],
|
|
258
|
+
stageOutput: lastKnownGoodStage === undefined ? 'empty' : 'last-known-good',
|
|
259
|
+
lastKnownGoodStage:
|
|
260
|
+
lastKnownGoodStage === undefined
|
|
261
|
+
? undefined
|
|
262
|
+
: {
|
|
263
|
+
fingerprint: lastKnownGoodStage.fingerprint,
|
|
264
|
+
generation: lastKnownGoodStage.generation,
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function stageRecoveryReadiness(
|
|
270
|
+
plan: VfxValidatedStagePlan,
|
|
271
|
+
generation: number,
|
|
272
|
+
recovery: VfxStageRecovery,
|
|
273
|
+
): readonly VfxStageReadiness[] {
|
|
274
|
+
return plan.stages.map((stage) => ({
|
|
275
|
+
id: stage.id,
|
|
276
|
+
state: recovery === 'stale' ? ('stale' as const) : ('rebuilding' as const),
|
|
277
|
+
generation,
|
|
278
|
+
lastKnownGoodGeneration: plan.generation,
|
|
279
|
+
retryable: true,
|
|
280
|
+
error: recovery,
|
|
281
|
+
}));
|
|
282
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ok, type Result } from '@forgeax/engine-types';
|
|
2
|
+
|
|
3
|
+
export interface BeamTopologyInput {
|
|
4
|
+
readonly capacity: number;
|
|
5
|
+
readonly endpointField: 'velocity';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface BeamTopologyOutput {
|
|
9
|
+
readonly topology: 'beam';
|
|
10
|
+
readonly vertexCount: number;
|
|
11
|
+
readonly indexCount: number;
|
|
12
|
+
readonly indirectVertexCount: number;
|
|
13
|
+
readonly dropped: number;
|
|
14
|
+
readonly degenerate: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function buildBeamTopology(
|
|
18
|
+
input: BeamTopologyInput,
|
|
19
|
+
requested: number,
|
|
20
|
+
degenerate = 0,
|
|
21
|
+
): Result<BeamTopologyOutput, never> {
|
|
22
|
+
const produced = Math.min(input.capacity, Math.max(0, requested));
|
|
23
|
+
return ok({
|
|
24
|
+
topology: 'beam',
|
|
25
|
+
vertexCount: produced * 2,
|
|
26
|
+
indexCount: produced * 6,
|
|
27
|
+
indirectVertexCount: produced * 6,
|
|
28
|
+
dropped: Math.max(0, requested - produced),
|
|
29
|
+
degenerate: Math.max(0, degenerate),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function beamIsDegenerate(
|
|
34
|
+
start: readonly [number, number, number],
|
|
35
|
+
endpoint: readonly [number, number, number],
|
|
36
|
+
): boolean {
|
|
37
|
+
const dx = endpoint[0] - start[0];
|
|
38
|
+
const dy = endpoint[1] - start[1];
|
|
39
|
+
const dz = endpoint[2] - start[2];
|
|
40
|
+
return dx * dx + dy * dy + dz * dz <= 1e-10;
|
|
41
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ok, type Result } from '@forgeax/engine-types';
|
|
2
|
+
|
|
3
|
+
export interface RibbonTopologyInput {
|
|
4
|
+
readonly capacity: number;
|
|
5
|
+
readonly stripKey: 'alive-index';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface RibbonTopologyOutput {
|
|
9
|
+
readonly topology: 'ribbon';
|
|
10
|
+
readonly vertexCount: number;
|
|
11
|
+
readonly indexCount: number;
|
|
12
|
+
readonly indirectVertexCount: number;
|
|
13
|
+
readonly dropped: number;
|
|
14
|
+
readonly brokenStrips: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function buildRibbonTopology(
|
|
18
|
+
input: RibbonTopologyInput,
|
|
19
|
+
requested: number,
|
|
20
|
+
brokenStrips = 0,
|
|
21
|
+
): Result<RibbonTopologyOutput, never> {
|
|
22
|
+
const produced = Math.min(input.capacity, Math.max(0, requested));
|
|
23
|
+
return ok({
|
|
24
|
+
topology: 'ribbon',
|
|
25
|
+
vertexCount: produced * 2,
|
|
26
|
+
indexCount: Math.max(0, produced - 1) * 6,
|
|
27
|
+
indirectVertexCount: Math.max(0, produced - 1) * 6,
|
|
28
|
+
dropped: Math.max(0, requested - produced),
|
|
29
|
+
brokenStrips: Math.max(0, brokenStrips),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { ok, type Result } from '@forgeax/engine-types';
|
|
2
|
+
|
|
3
|
+
export interface TrailTopologyInput {
|
|
4
|
+
readonly capacity: number;
|
|
5
|
+
readonly historyLength: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface TrailTopologyOutput {
|
|
9
|
+
readonly topology: 'trail';
|
|
10
|
+
readonly vertexCount: number;
|
|
11
|
+
readonly indexCount: number;
|
|
12
|
+
readonly indirectVertexCount: number;
|
|
13
|
+
readonly dropped: number;
|
|
14
|
+
readonly brokenTrails: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function buildTrailTopology(
|
|
18
|
+
input: TrailTopologyInput,
|
|
19
|
+
requested: number,
|
|
20
|
+
brokenTrails = 0,
|
|
21
|
+
): Result<TrailTopologyOutput, never> {
|
|
22
|
+
const produced = Math.min(input.capacity, Math.max(0, requested));
|
|
23
|
+
const segments = Math.max(0, input.historyLength - 1);
|
|
24
|
+
return ok({
|
|
25
|
+
topology: 'trail',
|
|
26
|
+
vertexCount: produced * input.historyLength * 2,
|
|
27
|
+
indexCount: produced * segments * 6,
|
|
28
|
+
indirectVertexCount: produced * segments * 6,
|
|
29
|
+
dropped: Math.max(0, requested - produced),
|
|
30
|
+
brokenTrails: Math.max(0, brokenTrails),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function trailContinuityBreaks(
|
|
35
|
+
samples: readonly { readonly id: number; readonly connected: boolean }[],
|
|
36
|
+
): number {
|
|
37
|
+
let breaks = 0;
|
|
38
|
+
for (let index = 1; index < samples.length; index += 1) {
|
|
39
|
+
if (samples[index]?.id !== samples[index - 1]?.id || samples[index]?.connected === false)
|
|
40
|
+
breaks += 1;
|
|
41
|
+
}
|
|
42
|
+
return breaks;
|
|
43
|
+
}
|