@forgeax/engine-vfx-render 0.1.7 → 0.1.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgeax/engine-vfx-render",
3
- "version": "0.1.7",
3
+ "version": "0.1.19",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -22,13 +22,13 @@
22
22
  "LICENSE"
23
23
  ],
24
24
  "dependencies": {
25
- "@forgeax/engine-assets-runtime": "0.1.7",
26
- "@forgeax/engine-ecs": "0.1.7",
27
- "@forgeax/engine-math": "0.1.7",
28
- "@forgeax/engine-render": "0.1.7",
29
- "@forgeax/engine-scene": "0.1.7",
30
- "@forgeax/engine-types": "0.1.7",
31
- "@forgeax/engine-vfx": "0.1.7"
25
+ "@forgeax/engine-assets-runtime": "0.1.19",
26
+ "@forgeax/engine-ecs": "0.1.19",
27
+ "@forgeax/engine-math": "0.1.19",
28
+ "@forgeax/engine-render": "0.1.19",
29
+ "@forgeax/engine-scene": "0.1.19",
30
+ "@forgeax/engine-types": "0.1.19",
31
+ "@forgeax/engine-vfx": "0.1.19"
32
32
  },
33
33
  "devDependencies": {},
34
34
  "forgeax": {
@@ -21,9 +21,11 @@ describe('VFX Fog producer contract', () => {
21
21
  );
22
22
  expect(source, name).toMatch(/#import\s+forgeax_view::fog::\{[^}]*\bapply_fog\b[^}]*\}/);
23
23
  expect(count(source, /\bapply_fog\s*\(/g), name).toBe(1);
24
- expect(count(source, /\bFogRay\s*\(/g), name).toBe(1);
25
- expect(source, name).toMatch(/world[_A-Za-z]*position|worldPosition/);
26
- expect(source, name).toMatch(/ray[_A-Za-z]*distance|distance/);
24
+ expect(count(source, /\bFogRay\s*\(/g) + count(source, /\bfogRayFromNdc\s*\(/g), name).toBe(
25
+ 1,
26
+ );
27
+ expect(source, name).toMatch(/world[_A-Za-z]*position|worldPosition|fogRayFromNdc/);
28
+ expect(source, name).toMatch(/ray[_A-Za-z]*distance|distance|fogRayFromNdc/);
27
29
  expect(source, name).toMatch(/\.a\b|alpha/);
28
30
  expect(source, name).not.toMatch(/texture_3d|raymarch/i);
29
31
  }
@@ -50,7 +50,10 @@ describe('GPU VFX public host', () => {
50
50
  const world = new World();
51
51
  const lookups: string[] = [];
52
52
  class StatefulAssets {
53
- readonly material: MaterialAsset = { kind: 'material', values: {} };
53
+ readonly material: MaterialAsset = {
54
+ kind: 'material',
55
+ passes: [{ name: 'particle-billboard', program: { module: 'custom-depth' } }],
56
+ };
54
57
  readonly loaders = { registerPackLoader: () => {} };
55
58
 
56
59
  lookup<T extends Asset = Asset>(guid: string): T | undefined {
@@ -118,14 +121,29 @@ describe('GPU VFX public host', () => {
118
121
  frameNumber: 1,
119
122
  } as never,
120
123
  {
121
- targets: [],
124
+ targets: [
125
+ { name: 'scene-color', kind: 'color', format: 'rgba8unorm-srgb', sampleCount: 1 },
126
+ { name: 'scene-depth', kind: 'depth', format: 'depth24plus', sampleCount: 1 },
127
+ ],
122
128
  caps: {},
123
129
  frame: { frameNumber: 1 },
124
130
  generation: 1,
131
+ materialShaderBindingContract: () => 'group-0-resource',
125
132
  } as never,
126
133
  );
127
134
  expect(planned.ok).toBe(true);
128
135
  expect(lookups).toEqual(['material-guid']);
136
+ if (planned.ok) {
137
+ expect(planned.value.resources).toEqual(
138
+ expect.arrayContaining([
139
+ expect.objectContaining({
140
+ kind: 'graphics-bindings',
141
+ values: expect.objectContaining({ sceneDepthBinding: 0 }),
142
+ logicalTargets: { sceneDepth: 'scene-depth' },
143
+ }),
144
+ ]),
145
+ );
146
+ }
129
147
  await host.detachWorld({ world });
130
148
  });
131
149
 
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
3
3
  import {
4
4
  canonicalMeshVertices,
5
5
  particleMaterialPass,
6
+ particleMaterialSceneDepthBinding,
6
7
  particleMaterialUsesBindings,
7
8
  } from '../feature/particle-resources.js';
8
9
 
@@ -53,6 +54,14 @@ describe('particle material pass', () => {
53
54
  });
54
55
  });
55
56
 
57
+ it('maps renderer shader contracts to their scene-depth binding slots', () => {
58
+ expect(particleMaterialSceneDepthBinding('group-0-resource')).toBe(0);
59
+ expect(particleMaterialSceneDepthBinding('view-and-scene-depth')).toBe(1);
60
+ expect(particleMaterialSceneDepthBinding('group-0')).toBeUndefined();
61
+ expect(particleMaterialSceneDepthBinding('view-only')).toBeUndefined();
62
+ expect(particleMaterialSceneDepthBinding('render-material')).toBeUndefined();
63
+ });
64
+
56
65
  it('does not mistake an ordinary Forward pass for a particle shader', () => {
57
66
  const material: MaterialAsset = {
58
67
  kind: 'material',
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
- import type { RenderFeaturePlan } from '@forgeax/engine-render';
3
+ import { createSceneDataCatalog, type RenderFeaturePlan } from '@forgeax/engine-render';
4
4
  import type { VfxGpuTickIntent } from '@forgeax/engine-vfx';
5
5
  import { describe, expect, it } from 'vitest';
6
6
  import { freezeRenderFeaturePlan } from '../../../render/src/features/plan';
@@ -108,6 +108,12 @@ describe('VFX managed stage execution seam', () => {
108
108
  { name: 'scene-color', kind: 'color', format: 'rgba16float', sampleCount: 1 },
109
109
  { name: 'scene-depth', kind: 'depth', format: 'depth24plus', sampleCount: 1 },
110
110
  ],
111
+ sceneData: createSceneDataCatalog({
112
+ featureIdentity: feature.identity,
113
+ generation: 1,
114
+ planIdentity: `${feature.identity}:1`,
115
+ rgba16floatRenderable: true,
116
+ }),
111
117
  },
112
118
  );
113
119
 
@@ -25,6 +25,7 @@ import {
25
25
  createTopologyResourcePlan,
26
26
  PARTICLE_SHADER_IDENTIFIERS,
27
27
  particleMaterialPass,
28
+ particleMaterialSceneDepthBinding,
28
29
  particleMaterialUsesBindings,
29
30
  } from './particle-resources.js';
30
31
  import type { VfxStagePlanObservation, VfxValidatedStagePlan } from './stage-plan.js';
@@ -346,6 +347,11 @@ export function gpuParticleRenderFeature(
346
347
  return {
347
348
  identity: IDENTITY,
348
349
  requiredCapabilities: ['compute', 'indirectDrawing'],
350
+ // Generated emitter programs are first-use assets. They must hand a
351
+ // WebGPU module to the prepared feature pipeline without waiting for the
352
+ // diagnostic-only getCompilationInfo() round trip; pipeline creation still
353
+ // validates the module before the pass is submitted.
354
+ shaderModuleMode: 'immediate',
349
355
  requiredMaterialShaders: Object.values(PARTICLE_SHADER_IDENTIFIERS),
350
356
  extract: (context) => {
351
357
  const extracted: ExtractedWorld[] = [];
@@ -552,6 +558,14 @@ export function gpuParticleRenderFeature(
552
558
  const submesh =
553
559
  renderer.kind === 'mesh' ? mesh?.submeshes[renderer.submesh ?? 0] : undefined;
554
560
  const indexFormat = mesh?.indices instanceof Uint32Array ? 'uint32' : 'uint16';
561
+ const sceneDepthBinding = isBillboard
562
+ ? particleMaterialSceneDepthBinding(
563
+ context.materialShaderBindingContract?.(materialPass.shader) ??
564
+ (materialPass.shader === PARTICLE_SHADER_IDENTIFIERS.billboard
565
+ ? 'view-and-scene-depth'
566
+ : undefined),
567
+ )
568
+ : undefined;
555
569
  const instances = `${rendererPrefix}.instances`;
556
570
  const history = `${rendererPrefix}.history`;
557
571
  const projectionRuntime = `${rendererPrefix}.runtime`;
@@ -690,9 +704,9 @@ export function gpuParticleRenderFeature(
690
704
  group: 0,
691
705
  runtime: projectionRuntime,
692
706
  instances,
693
- ...(isBillboard ? { sceneDepthBinding: 1 } : {}),
707
+ ...(sceneDepthBinding === undefined ? {} : { sceneDepthBinding }),
694
708
  },
695
- ...(isBillboard && depthTarget !== undefined
709
+ ...(sceneDepthBinding !== undefined && depthTarget !== undefined
696
710
  ? { logicalTargets: { sceneDepth: depthTarget.name } }
697
711
  : {}),
698
712
  },
@@ -784,7 +798,7 @@ export function gpuParticleRenderFeature(
784
798
  depthStoreOp: 'store',
785
799
  },
786
800
  }),
787
- ...(isBillboard && depthTarget !== undefined
801
+ ...(sceneDepthBinding !== undefined && depthTarget !== undefined
788
802
  ? { sampledTargets: [depthTarget.name] }
789
803
  : {}),
790
804
  draws: [
@@ -1,3 +1,4 @@
1
+ import type { RenderFeatureMaterialShaderBindingContract } from '@forgeax/engine-render';
1
2
  import {
2
3
  err,
3
4
  type MaterialAsset,
@@ -161,6 +162,23 @@ export function particleMaterialUsesBindings(material: MaterialAsset | undefined
161
162
  return (material?.parameters?.length ?? 0) > 0;
162
163
  }
163
164
 
165
+ /**
166
+ * Return the scene-depth binding slot required by a particle material shader.
167
+ *
168
+ * Custom particle shaders historically bind only the sampled depth resource at
169
+ * group(0)/binding(0), while the renderer-owned billboard shader also consumes
170
+ * the view UBO at binding(0) and therefore uses depth at binding(1). Keep this
171
+ * choice derived from the renderer contract instead of assuming every
172
+ * billboard has the built-in layout.
173
+ */
174
+ export function particleMaterialSceneDepthBinding(
175
+ contract: RenderFeatureMaterialShaderBindingContract | undefined,
176
+ ): 0 | 1 | undefined {
177
+ if (contract === 'group-0-resource') return 0;
178
+ if (contract === 'view-and-scene-depth') return 1;
179
+ return undefined;
180
+ }
181
+
164
182
  function floatAttribute(value: ArrayBuffer | Float32Array | Uint16Array | undefined): Float32Array {
165
183
  if (value instanceof Float32Array) return value;
166
184
  if (value instanceof Uint16Array) return Float32Array.from(value);
@@ -1,5 +1,5 @@
1
1
  #define_import_path forgeax::vfx-render.particles.beam
2
- #import forgeax_view::common::{View, FogViewParams, FogRay, view}
2
+ #import forgeax_view::common::{View, FogViewParams, FogRay, view, fogRayFromNdc}
3
3
  #import forgeax_view::fog::{apply_fog}
4
4
 
5
5
  struct BeamInput {
@@ -15,29 +15,6 @@ struct VertexOutput {
15
15
  @location(1) clip_position: vec3<f32>,
16
16
  }
17
17
 
18
- fn fogWorldPoint(ndc: vec3<f32>) -> vec3<f32> {
19
- let homogeneous = view.inverseViewProj * vec4<f32>(ndc, 1.0);
20
- let divisor = select(1.0, homogeneous.w, abs(homogeneous.w) > 0.000001);
21
- return homogeneous.xyz / divisor;
22
- }
23
-
24
- fn fogRayFromNdc(ndc: vec3<f32>) -> FogRay {
25
- let worldPosition = fogWorldPoint(ndc);
26
- let nearPosition = fogWorldPoint(vec3<f32>(ndc.xy, 0.0));
27
- let farPosition = fogWorldPoint(vec3<f32>(ndc.xy, 1.0));
28
- let perspective = view.temporalProjection.z < 0.5;
29
- let perspectiveVector = worldPosition - view.cameraPos;
30
- let orthographicVector = farPosition - nearPosition;
31
- let direction = normalize(select(orthographicVector, perspectiveVector, perspective));
32
- let origin = select(nearPosition, view.cameraPos, perspective);
33
- let ray_distance = select(
34
- max(dot(worldPosition - nearPosition, direction), 0.0),
35
- length(perspectiveVector),
36
- perspective,
37
- );
38
- return FogRay(origin, direction, ray_distance);
39
- }
40
-
41
18
  @vertex
42
19
  fn vs_main(input: BeamInput, @builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
43
20
  let corners = array<vec2<f32>, 6>(
@@ -1,5 +1,5 @@
1
1
  #define_import_path forgeax::vfx-render.particles.billboard
2
- #import forgeax_view::common::{View, FogViewParams, FogRay, view}
2
+ #import forgeax_view::common::{View, FogViewParams, FogRay, view, fogRayFromNdc}
3
3
  #import forgeax_view::fog::{apply_fog}
4
4
 
5
5
  @group(0) @binding(1) var scene_depth: texture_depth_2d;
@@ -51,29 +51,6 @@ fn softParticle(position: vec4<f32>, alpha: f32, fadeDistance: f32) -> f32 {
51
51
  return alpha * softParticleFactor(position.z, sceneDepth, fadeDistance);
52
52
  }
53
53
 
54
- fn fogWorldPoint(ndc: vec3<f32>) -> vec3<f32> {
55
- let homogeneous = view.inverseViewProj * vec4<f32>(ndc, 1.0);
56
- let divisor = select(1.0, homogeneous.w, abs(homogeneous.w) > 0.000001);
57
- return homogeneous.xyz / divisor;
58
- }
59
-
60
- fn fogRayFromNdc(ndc: vec3<f32>) -> FogRay {
61
- let worldPosition = fogWorldPoint(ndc);
62
- let nearPosition = fogWorldPoint(vec3<f32>(ndc.xy, 0.0));
63
- let farPosition = fogWorldPoint(vec3<f32>(ndc.xy, 1.0));
64
- let perspective = view.temporalProjection.z < 0.5;
65
- let perspectiveVector = worldPosition - view.cameraPos;
66
- let orthographicVector = farPosition - nearPosition;
67
- let direction = normalize(select(orthographicVector, perspectiveVector, perspective));
68
- let origin = select(nearPosition, view.cameraPos, perspective);
69
- let ray_distance = select(
70
- max(dot(worldPosition - nearPosition, direction), 0.0),
71
- length(perspectiveVector),
72
- perspective,
73
- );
74
- return FogRay(origin, direction, ray_distance);
75
- }
76
-
77
54
  struct VertexInput {
78
55
  @location(0) position: vec3<f32>,
79
56
  @location(1) right: vec2<f32>,
@@ -1,5 +1,5 @@
1
1
  #define_import_path forgeax::vfx-render.particles.mesh
2
- #import forgeax_view::common::{View, FogViewParams, FogRay, view}
2
+ #import forgeax_view::common::{View, FogViewParams, FogRay, view, fogRayFromNdc}
3
3
  #import forgeax_view::fog::{apply_fog}
4
4
 
5
5
  struct VertexOutput {
@@ -26,29 +26,6 @@ struct VertexInput {
26
26
  @location(11) surface: vec4<f32>,
27
27
  };
28
28
 
29
- fn fogWorldPoint(ndc: vec3<f32>) -> vec3<f32> {
30
- let homogeneous = view.inverseViewProj * vec4<f32>(ndc, 1.0);
31
- let divisor = select(1.0, homogeneous.w, abs(homogeneous.w) > 0.000001);
32
- return homogeneous.xyz / divisor;
33
- }
34
-
35
- fn fogRayFromNdc(ndc: vec3<f32>) -> FogRay {
36
- let worldPosition = fogWorldPoint(ndc);
37
- let nearPosition = fogWorldPoint(vec3<f32>(ndc.xy, 0.0));
38
- let farPosition = fogWorldPoint(vec3<f32>(ndc.xy, 1.0));
39
- let perspective = view.temporalProjection.z < 0.5;
40
- let perspectiveVector = worldPosition - view.cameraPos;
41
- let orthographicVector = farPosition - nearPosition;
42
- let direction = normalize(select(orthographicVector, perspectiveVector, perspective));
43
- let origin = select(nearPosition, view.cameraPos, perspective);
44
- let ray_distance = select(
45
- max(dot(worldPosition - nearPosition, direction), 0.0),
46
- length(perspectiveVector),
47
- perspective,
48
- );
49
- return FogRay(origin, direction, ray_distance);
50
- }
51
-
52
29
  @vertex
53
30
  fn vs_main(input: VertexInput) -> VertexOutput {
54
31
  var output: VertexOutput;
@@ -1,5 +1,5 @@
1
1
  #define_import_path forgeax::vfx-render.particles.ribbon
2
- #import forgeax_view::common::{View, FogViewParams, FogRay, view}
2
+ #import forgeax_view::common::{View, FogViewParams, FogRay, view, fogRayFromNdc}
3
3
  #import forgeax_view::fog::{apply_fog}
4
4
 
5
5
  struct SegmentInput {
@@ -15,29 +15,6 @@ struct VertexOutput {
15
15
  @location(1) clip_position: vec3<f32>,
16
16
  }
17
17
 
18
- fn fogWorldPoint(ndc: vec3<f32>) -> vec3<f32> {
19
- let homogeneous = view.inverseViewProj * vec4<f32>(ndc, 1.0);
20
- let divisor = select(1.0, homogeneous.w, abs(homogeneous.w) > 0.000001);
21
- return homogeneous.xyz / divisor;
22
- }
23
-
24
- fn fogRayFromNdc(ndc: vec3<f32>) -> FogRay {
25
- let worldPosition = fogWorldPoint(ndc);
26
- let nearPosition = fogWorldPoint(vec3<f32>(ndc.xy, 0.0));
27
- let farPosition = fogWorldPoint(vec3<f32>(ndc.xy, 1.0));
28
- let perspective = view.temporalProjection.z < 0.5;
29
- let perspectiveVector = worldPosition - view.cameraPos;
30
- let orthographicVector = farPosition - nearPosition;
31
- let direction = normalize(select(orthographicVector, perspectiveVector, perspective));
32
- let origin = select(nearPosition, view.cameraPos, perspective);
33
- let ray_distance = select(
34
- max(dot(worldPosition - nearPosition, direction), 0.0),
35
- length(perspectiveVector),
36
- perspective,
37
- );
38
- return FogRay(origin, direction, ray_distance);
39
- }
40
-
41
18
  @vertex
42
19
  fn vs_main(input: SegmentInput, @builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
43
20
  let corners = array<vec2<f32>, 6>(
@@ -1,5 +1,5 @@
1
1
  #define_import_path forgeax::vfx-render.particles.trail
2
- #import forgeax_view::common::{View, FogViewParams, FogRay, view}
2
+ #import forgeax_view::common::{View, FogViewParams, FogRay, view, fogRayFromNdc}
3
3
  #import forgeax_view::fog::{apply_fog}
4
4
 
5
5
  struct SegmentInput {
@@ -15,29 +15,6 @@ struct VertexOutput {
15
15
  @location(1) clip_position: vec3<f32>,
16
16
  }
17
17
 
18
- fn fogWorldPoint(ndc: vec3<f32>) -> vec3<f32> {
19
- let homogeneous = view.inverseViewProj * vec4<f32>(ndc, 1.0);
20
- let divisor = select(1.0, homogeneous.w, abs(homogeneous.w) > 0.000001);
21
- return homogeneous.xyz / divisor;
22
- }
23
-
24
- fn fogRayFromNdc(ndc: vec3<f32>) -> FogRay {
25
- let worldPosition = fogWorldPoint(ndc);
26
- let nearPosition = fogWorldPoint(vec3<f32>(ndc.xy, 0.0));
27
- let farPosition = fogWorldPoint(vec3<f32>(ndc.xy, 1.0));
28
- let perspective = view.temporalProjection.z < 0.5;
29
- let perspectiveVector = worldPosition - view.cameraPos;
30
- let orthographicVector = farPosition - nearPosition;
31
- let direction = normalize(select(orthographicVector, perspectiveVector, perspective));
32
- let origin = select(nearPosition, view.cameraPos, perspective);
33
- let ray_distance = select(
34
- max(dot(worldPosition - nearPosition, direction), 0.0),
35
- length(perspectiveVector),
36
- perspective,
37
- );
38
- return FogRay(origin, direction, ray_distance);
39
- }
40
-
41
18
  @vertex
42
19
  fn vs_main(input: SegmentInput, @builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
43
20
  let corners = array<vec2<f32>, 6>(