@fps-games/vfx 0.3.6 → 0.3.7

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/CHANGELOG.md CHANGED
@@ -12,6 +12,14 @@
12
12
 
13
13
  <!-- release.mjs 在此行下方插入新版本区块 -->
14
14
 
15
+ ## v0.3.7 — 2026-07-02
16
+
17
+ ### 改动
18
+ - feat(effects): update trail debris shield and building upgrade controls
19
+
20
+ ### 特效
21
+ - 无新增 / 删除(仍 37 个)。
22
+
15
23
  ## v0.3.6 — 2026-07-01
16
24
 
17
25
  ### 改动
@@ -1,13 +1,10 @@
1
1
  import { VfxColorValue, VfxEffectPackage, VfxParamValues } from '../../types';
2
+ type BuildingUpgradeFootprintShape = 'rectangle' | 'circle';
2
3
  interface BuildingUpgradeParams extends VfxParamValues {
3
4
  lifetime: number;
4
- scale: number;
5
- initialHeight: number;
6
5
  color: VfxColorValue;
7
6
  opacity: number;
8
- scaleX: number;
9
- scaleY: number;
10
- scaleZ: number;
7
+ footprintShape: BuildingUpgradeFootprintShape;
11
8
  centerCount: number;
12
9
  outlineCount: number;
13
10
  strandWidth: number;
@@ -15,18 +15,15 @@ const manifest = {
15
15
  };
16
16
  const BASE_FOOTPRINT_X = 3.2;
17
17
  const BASE_FOOTPRINT_Z = 2.6;
18
+ const BASE_CIRCLE_RADIUS = 1.4;
18
19
  const BASE_CENTER_HEIGHT = 2.2;
19
20
  const BASE_OUTLINE_HEIGHT = 3.1;
20
21
  const BASE_GLOW_THICKNESS = 0.38;
21
22
  const DEFAULT_PARAMS = {
22
23
  lifetime: 1.8,
23
- scale: 1,
24
- initialHeight: 0.02,
25
24
  color: { r: 1, g: 0.84, b: 0.12 },
26
25
  opacity: 1,
27
- scaleX: 1,
28
- scaleY: 1,
29
- scaleZ: 1,
26
+ footprintShape: "rectangle",
30
27
  centerCount: 46,
31
28
  outlineCount: 150,
32
29
  strandWidth: 0.035,
@@ -43,13 +40,12 @@ const buildingUpgradeEffectPackage = {
43
40
  debugAnchor: "playerRight",
44
41
  debugParams: [
45
42
  { key: "lifetime", label: "生命周期", kind: "lifetime", min: 0.2, max: 6, step: 0.01 },
46
- { key: "scale", label: "整体缩放", kind: "scale", min: 0.1, max: 6, step: 0.01 },
47
- { key: "initialHeight", label: "初始高度", kind: "number", min: -1, max: 3, step: 0.01 },
48
43
  { key: "color", label: "金光颜色", kind: "color" },
49
44
  { key: "opacity", label: "透明度", kind: "opacity", min: 0, max: 1, step: 0.01 },
50
- { key: "scaleX", label: "X 轴缩放", kind: "number", min: 0.1, max: 6, step: 0.01 },
51
- { key: "scaleY", label: "Y 轴缩放", kind: "number", min: 0.1, max: 6, step: 0.01 },
52
- { key: "scaleZ", label: "Z 轴缩放", kind: "number", min: 0.1, max: 6, step: 0.01 },
45
+ { key: "footprintShape", label: "发射形状", kind: "enum", options: [
46
+ { value: "rectangle", label: "矩形" },
47
+ { value: "circle", label: "圆形" }
48
+ ] },
53
49
  { key: "centerCount", label: "中心金丝数量", kind: "number", min: 0, max: 240, step: 1 },
54
50
  { key: "outlineCount", label: "轮廓金丝数量", kind: "number", min: 0, max: 420, step: 1 },
55
51
  { key: "strandWidth", label: "金丝粗细", kind: "number", min: 5e-3, max: 0.2, step: 1e-3 },
@@ -58,14 +54,15 @@ const buildingUpgradeEffectPackage = {
58
54
  { key: "outlineThickness", label: "轮廓厚度", kind: "number", min: 0, max: 1, step: 0.01 },
59
55
  { key: "glowOpacity", label: "光芒强度", kind: "opacity", min: 0, max: 1, step: 0.01 }
60
56
  ],
61
- create({ scene, root, position, params }) {
62
- root.position.copyFrom(position).addInPlace(new Vector3(0, params.initialHeight, 0));
63
- const centerStrands = createStrandSystem(scene, root, params, "center");
64
- const outlineStrands = createStrandSystem(scene, root, params, "outline");
65
- const glow = createOutlineGlow(scene, root, params);
57
+ create({ scene, root, position, params, scale }) {
58
+ root.position.copyFrom(position);
59
+ const visualScale = normalizeBuildingUpgradeScale(scale);
60
+ const centerStrands = createStrandSystem(scene, root, params, visualScale, "center");
61
+ const outlineStrands = createStrandSystem(scene, root, params, visualScale, "outline");
62
+ const glow = createOutlineGlow(scene, root, params, visualScale);
66
63
  const disposeTimer = window.setTimeout(
67
64
  () => disposeAll(),
68
- Math.max(0.05, params.lifetime + getMaxStrandHeight(params) / Math.max(0.1, params.riseSpeed) * 0.18 + 0.45) * 1e3
65
+ Math.max(0.05, params.lifetime + getMaxStrandHeight(visualScale) / Math.max(0.1, params.riseSpeed) * 0.18 + 0.45) * 1e3
69
66
  );
70
67
  let disposed = false;
71
68
  centerStrands.start();
@@ -88,12 +85,12 @@ const buildingUpgradeEffectPackage = {
88
85
  }
89
86
  }
90
87
  };
91
- function createStrandSystem(scene, root, params, mode) {
88
+ function createStrandSystem(scene, root, params, visualScale, mode) {
92
89
  const count = Math.max(0, Math.round(mode === "center" ? params.centerCount : params.outlineCount));
93
90
  const system = new ParticleSystem(`building-upgrade.${mode}-strands`, Math.max(1, count), scene);
94
91
  const color = params.color;
95
- const height = mode === "center" ? getCenterHeight(params) : getOutlineHeight(params);
96
- const width = params.strandWidth * getHorizontalScale(params) * (mode === "center" ? 1.35 : 0.92);
92
+ const height = mode === "center" ? getCenterHeight(visualScale) : getOutlineHeight(visualScale);
93
+ const width = params.strandWidth * visualScale.horizontal * (mode === "center" ? 1.35 : 0.92);
97
94
  configureVfxParticleSystem(system);
98
95
  system.particleTexture = getGoldStrandTexture(scene);
99
96
  system.emitter = root;
@@ -110,9 +107,9 @@ function createStrandSystem(scene, root, params, mode) {
110
107
  system.maxScaleX = Math.max(system.minScaleX, width * 1.8);
111
108
  system.minScaleY = 1;
112
109
  system.maxScaleY = mode === "center" ? 1.45 : 1.75;
113
- system.minEmitPower = Math.max(0.01, params.riseSpeed * getVerticalScale(params) * (mode === "center" ? 0.5 : 0.7));
114
- system.maxEmitPower = Math.max(system.minEmitPower, params.riseSpeed * getVerticalScale(params) * (mode === "center" ? 1.15 : 1.65));
115
- system.gravity = new Vector3(0, 0.38 * getVerticalScale(params), 0);
110
+ system.minEmitPower = Math.max(0.01, params.riseSpeed * (mode === "center" ? 0.5 : 0.7));
111
+ system.maxEmitPower = Math.max(system.minEmitPower, params.riseSpeed * (mode === "center" ? 1.15 : 1.65));
112
+ system.gravity = new Vector3(0, 0.38, 0);
116
113
  system.blendMode = ParticleSystem.BLENDMODE_ADD;
117
114
  system.color1 = toColor4(color, params.opacity * (mode === "center" ? 0.72 : 0.86));
118
115
  system.color2 = toColor4({ r: 1, g: 0.98, b: 0.52 }, params.opacity);
@@ -136,7 +133,7 @@ function createStrandSystem(scene, root, params, mode) {
136
133
  system.startDirectionFunction = (worldMatrix, directionToUpdate, particle, isLocal) => {
137
134
  const index = particle.id + directionIndex;
138
135
  directionIndex += 1;
139
- const sway = (mode === "center" ? 0.22 : 0.12) * getHorizontalScale(params);
136
+ const sway = mode === "center" ? 0.22 : 0.12;
140
137
  const local = new Vector3(
141
138
  (sequence01(index, 4) - 0.5) * sway,
142
139
  0.8 + sequence01(index, 5) * 0.65,
@@ -147,11 +144,11 @@ function createStrandSystem(scene, root, params, mode) {
147
144
  };
148
145
  return system;
149
146
  }
150
- function createOutlineGlow(scene, root, params) {
147
+ function createOutlineGlow(scene, root, params, visualScale) {
151
148
  const count = Math.max(1, Math.round(params.outlineCount * 0.82));
152
149
  const system = new ParticleSystem("building-upgrade.outline-glow-rays", count, scene);
153
- const height = getOutlineHeight(params) * 1.06;
154
- const glowWidth = Math.max(0.04, BASE_GLOW_THICKNESS * getHorizontalScale(params));
150
+ const height = getOutlineHeight(visualScale) * 1.06;
151
+ const glowWidth = Math.max(0.04, BASE_GLOW_THICKNESS * visualScale.horizontal);
155
152
  const alpha = params.glowOpacity * params.opacity;
156
153
  configureVfxParticleSystem(system);
157
154
  system.particleTexture = getOutlineGlowTexture(scene);
@@ -169,9 +166,9 @@ function createOutlineGlow(scene, root, params) {
169
166
  system.maxScaleX = glowWidth * 1.18;
170
167
  system.minScaleY = 1.05;
171
168
  system.maxScaleY = 1.75;
172
- system.minEmitPower = Math.max(0.01, params.riseSpeed * getVerticalScale(params) * 0.58);
173
- system.maxEmitPower = Math.max(system.minEmitPower, params.riseSpeed * getVerticalScale(params) * 1.38);
174
- system.gravity = new Vector3(0, 0.3 * getVerticalScale(params), 0);
169
+ system.minEmitPower = Math.max(0.01, params.riseSpeed * 0.58);
170
+ system.maxEmitPower = Math.max(system.minEmitPower, params.riseSpeed * 1.38);
171
+ system.gravity = new Vector3(0, 0.3, 0);
175
172
  system.blendMode = ParticleSystem.BLENDMODE_ADD;
176
173
  system.color1 = toColor4(params.color, alpha * 0.62);
177
174
  system.color2 = toColor4({ r: 1, g: 0.98, b: 0.58 }, alpha * 0.86);
@@ -195,7 +192,7 @@ function createOutlineGlow(scene, root, params) {
195
192
  system.startDirectionFunction = (worldMatrix, directionToUpdate, particle, isLocal) => {
196
193
  const index = particle.id + directionIndex + 31;
197
194
  directionIndex += 1;
198
- const sway = 0.08 * getHorizontalScale(params);
195
+ const sway = 0.08;
199
196
  const local = new Vector3(
200
197
  (sequence01(index, 4) - 0.5) * sway,
201
198
  0.82 + sequence01(index, 5) * 0.58,
@@ -207,49 +204,78 @@ function createOutlineGlow(scene, root, params) {
207
204
  return system;
208
205
  }
209
206
  function sampleCenterPosition(index, params) {
207
+ const shape = getFootprintShape(params);
208
+ if (shape === "circle") return sampleCircleCenterPosition(index, params);
210
209
  const angle = sequence01(index, 0) * Math.PI * 2;
211
210
  const radius = Math.sqrt(0.16 + sequence01(index, 1) * 0.84);
212
- const xRadius = getFootprintX(params) * 0.5 * params.centerSpread;
213
- const zRadius = getFootprintZ(params) * 0.5 * params.centerSpread;
211
+ const xRadius = getFootprintX() * 0.5 * params.centerSpread;
212
+ const zRadius = getFootprintZ() * 0.5 * params.centerSpread;
214
213
  const jitterX = (sequence01(index, 7) - 0.5) * xRadius * 0.18;
215
214
  const jitterZ = (sequence01(index, 8) - 0.5) * zRadius * 0.18;
216
215
  const x = Math.cos(angle) * radius * xRadius + jitterX;
217
216
  const z = Math.sin(angle) * radius * zRadius + jitterZ;
218
- const y = sequence01(index, 2) * 0.12 * getVerticalScale(params);
217
+ const y = sequence01(index, 2) * 0.12;
219
218
  return new Vector3(x, y, z);
220
219
  }
221
220
  function sampleOutlinePosition(index, params) {
222
- const width = getFootprintX(params);
223
- const depth = getFootprintZ(params);
221
+ const shape = getFootprintShape(params);
222
+ if (shape === "circle") return sampleCircleOutlinePosition(index, params);
223
+ return sampleRectOutlinePosition(index, getFootprintX(), getFootprintZ(), params.outlineThickness);
224
+ }
225
+ function sampleCircleCenterPosition(index, params) {
226
+ const angle = sequence01(index, 0) * Math.PI * 2;
227
+ const radius = Math.sqrt(sequence01(index, 1)) * BASE_CIRCLE_RADIUS * params.centerSpread;
228
+ const jitterRadius = (sequence01(index, 7) - 0.5) * BASE_CIRCLE_RADIUS * 0.08;
229
+ const r = Math.max(0, radius + jitterRadius);
230
+ const y = sequence01(index, 2) * 0.12;
231
+ return new Vector3(Math.cos(angle) * r, y, Math.sin(angle) * r);
232
+ }
233
+ function sampleCircleOutlinePosition(index, params) {
234
+ const angle = sequence01(index, 0) * Math.PI * 2;
235
+ const jitter = (sequence01(index, 1) - 0.5) * params.outlineThickness;
236
+ const radius = Math.max(1e-3, BASE_CIRCLE_RADIUS + jitter);
237
+ const y = sequence01(index, 2) * 0.16;
238
+ return new Vector3(Math.cos(angle) * radius, y, Math.sin(angle) * radius);
239
+ }
240
+ function sampleRectOutlinePosition(index, width, depth, thickness) {
224
241
  const perimeter = Math.max(1e-3, width * 2 + depth * 2);
225
242
  const distance = sequence01(index, 0) * perimeter;
226
- const jitter = (sequence01(index, 1) - 0.5) * params.outlineThickness * getHorizontalScale(params);
227
- const y = sequence01(index, 2) * 0.16 * getVerticalScale(params);
243
+ const jitter = (sequence01(index, 1) - 0.5) * thickness;
244
+ const y = sequence01(index, 2) * 0.16;
228
245
  if (distance < width) return new Vector3(-width * 0.5 + distance, y, -depth * 0.5 + jitter);
229
246
  if (distance < width + depth) return new Vector3(width * 0.5 + jitter, y, -depth * 0.5 + distance - width);
230
247
  if (distance < width * 2 + depth) return new Vector3(width * 0.5 - (distance - width - depth), y, depth * 0.5 + jitter);
231
248
  return new Vector3(-width * 0.5 + jitter, y, depth * 0.5 - (distance - width * 2 - depth));
232
249
  }
233
- function getFootprintX(params) {
234
- return BASE_FOOTPRINT_X * Math.max(0.05, params.scale) * Math.max(0.05, params.scaleX);
250
+ function getFootprintShape(params) {
251
+ if (params.footprintShape === "circle") return params.footprintShape;
252
+ return "rectangle";
235
253
  }
236
- function getFootprintZ(params) {
237
- return BASE_FOOTPRINT_Z * Math.max(0.05, params.scale) * Math.max(0.05, params.scaleZ);
254
+ function getFootprintX() {
255
+ return BASE_FOOTPRINT_X;
238
256
  }
239
- function getVerticalScale(params) {
240
- return Math.max(0.05, params.scale) * Math.max(0.05, params.scaleY);
257
+ function getFootprintZ() {
258
+ return BASE_FOOTPRINT_Z;
241
259
  }
242
- function getHorizontalScale(params) {
243
- return Math.max(0.05, params.scale) * Math.max(0.05, (params.scaleX + params.scaleZ) * 0.5);
260
+ function getCenterHeight(visualScale) {
261
+ return BASE_CENTER_HEIGHT * visualScale.y;
244
262
  }
245
- function getCenterHeight(params) {
246
- return BASE_CENTER_HEIGHT * getVerticalScale(params);
263
+ function getOutlineHeight(visualScale) {
264
+ return BASE_OUTLINE_HEIGHT * visualScale.y;
247
265
  }
248
- function getOutlineHeight(params) {
249
- return BASE_OUTLINE_HEIGHT * getVerticalScale(params);
266
+ function getMaxStrandHeight(visualScale) {
267
+ return Math.max(getCenterHeight(visualScale), getOutlineHeight(visualScale));
250
268
  }
251
- function getMaxStrandHeight(params) {
252
- return Math.max(getCenterHeight(params), getOutlineHeight(params));
269
+ function normalizeBuildingUpgradeScale(scale) {
270
+ const x = Math.max(0.05, Math.abs(scale.x));
271
+ const y = Math.max(0.05, Math.abs(scale.y));
272
+ const z = Math.max(0.05, Math.abs(scale.z));
273
+ return {
274
+ x,
275
+ y,
276
+ z,
277
+ horizontal: Math.max(0.05, (x + z) * 0.5)
278
+ };
253
279
  }
254
280
  function getGoldStrandTexture(scene) {
255
281
  const key = "__vfx_building_upgrade_gold_strand_texture";
@@ -3,6 +3,14 @@ interface DebrisSmokeParams extends VfxParamValues {
3
3
  debrisCount: number;
4
4
  debrisSize: number;
5
5
  debrisRadius: number;
6
+ debrisSpeed: number;
7
+ debrisHorizontalSpeed: number;
8
+ debrisUpSpeed: number;
9
+ debrisDirectionX: number;
10
+ debrisDirectionY: number;
11
+ debrisDirectionZ: number;
12
+ debrisGravity: number;
13
+ debrisLifetime: number;
6
14
  smokeCount: number;
7
15
  smokeSize: number;
8
16
  }
@@ -11,6 +11,14 @@ const DEFAULT_PARAMS = {
11
11
  debrisCount: 14,
12
12
  debrisSize: 0.35,
13
13
  debrisRadius: 1.8,
14
+ debrisSpeed: 1,
15
+ debrisHorizontalSpeed: 1,
16
+ debrisUpSpeed: 1,
17
+ debrisDirectionX: 0,
18
+ debrisDirectionY: 0,
19
+ debrisDirectionZ: 0,
20
+ debrisGravity: 7,
21
+ debrisLifetime: 1.2,
14
22
  smokeCount: 10,
15
23
  smokeSize: 1
16
24
  };
@@ -51,6 +59,14 @@ const debrisSmokeEffectPackage = {
51
59
  { key: "debrisCount", label: "碎石·数量", kind: "number", min: 0, max: 30, step: 1 },
52
60
  { key: "debrisSize", label: "碎石·大小", kind: "number", min: 0.1, max: 1, step: 0.01 },
53
61
  { key: "debrisRadius", label: "碎石·范围", kind: "scale", min: 0.5, max: 5, step: 0.1 },
62
+ { key: "debrisSpeed", label: "碎石·整体速度", kind: "number", min: 0, max: 5, step: 0.05 },
63
+ { key: "debrisHorizontalSpeed", label: "碎石·横向速度", kind: "number", min: 0, max: 5, step: 0.05 },
64
+ { key: "debrisUpSpeed", label: "碎石·上抛速度", kind: "number", min: 0, max: 5, step: 0.05 },
65
+ { key: "debrisDirectionX", label: "碎石·方向 X", kind: "number", min: -1, max: 1, step: 0.01 },
66
+ { key: "debrisDirectionY", label: "碎石·方向 Y", kind: "number", min: -1, max: 1, step: 0.01 },
67
+ { key: "debrisDirectionZ", label: "碎石·方向 Z", kind: "number", min: -1, max: 1, step: 0.01 },
68
+ { key: "debrisGravity", label: "碎石·下落重力", kind: "number", min: 0, max: 20, step: 0.1 },
69
+ { key: "debrisLifetime", label: "碎石·可见时长", kind: "lifetime", min: 0.2, max: 4, step: 0.05 },
54
70
  // 段2 · 烟雾
55
71
  { key: "smokeCount", label: "烟雾·数量", kind: "number", min: 0, max: 40, step: 1 },
56
72
  { key: "smokeSize", label: "烟雾·尺寸", kind: "scale", min: 0.2, max: 3, step: 0.05 }
@@ -85,12 +101,12 @@ const debrisSmokeEffectPackage = {
85
101
  d.isPickable = false;
86
102
  d.normalizeToUnitCube();
87
103
  d.scaling.scaleInPlace(r * params.debrisSize * (0.5 + Math.random() * 0.8));
88
- pool.spawn(d, position, new Vector3((Math.random() - 0.5) * 13, Math.random() * 6.75 + 2.25, (Math.random() - 0.5) * 13), 7, 1.2);
104
+ pool.spawn(d, position, makeDebrisVelocity(params), params.debrisGravity, params.debrisLifetime);
89
105
  }
90
106
  }).catch((e) => {
91
107
  console.warn("[VFX] debris-smoke load failed", e);
92
108
  });
93
- const disposeTimer = window.setTimeout(() => disposeAll(), 2200);
109
+ const disposeTimer = window.setTimeout(() => disposeAll(), Math.max(2200, params.debrisLifetime * 1e3 + 1e3));
94
110
  function disposeAll() {
95
111
  if (disposed) return;
96
112
  disposed = true;
@@ -104,6 +120,25 @@ const debrisSmokeEffectPackage = {
104
120
  } };
105
121
  }
106
122
  };
123
+ function makeDebrisVelocity(params) {
124
+ const velocity = new Vector3(
125
+ (Math.random() - 0.5) * 13 * params.debrisHorizontalSpeed,
126
+ (Math.random() * 6.75 + 2.25) * params.debrisUpSpeed,
127
+ (Math.random() - 0.5) * 13 * params.debrisHorizontalSpeed
128
+ );
129
+ const direction = new Vector3(params.debrisDirectionX, params.debrisDirectionY, params.debrisDirectionZ);
130
+ const directionLength = direction.length();
131
+ if (directionLength > 1e-4) {
132
+ direction.scaleInPlace(1 / directionLength);
133
+ const directionSpeed = 6.5 * Math.min(directionLength, 1);
134
+ velocity.addInPlace(new Vector3(
135
+ direction.x * directionSpeed * params.debrisHorizontalSpeed,
136
+ direction.y * directionSpeed * params.debrisUpSpeed,
137
+ direction.z * directionSpeed * params.debrisHorizontalSpeed
138
+ ));
139
+ }
140
+ return velocity.scaleInPlace(params.debrisSpeed);
141
+ }
107
142
  export {
108
143
  debrisSmokeEffectPackage
109
144
  };
@@ -7,6 +7,9 @@ import { MeshBuilder } from "@babylonjs/core/Meshes/meshBuilder";
7
7
  import { TransformNode } from "@babylonjs/core/Meshes/transformNode";
8
8
  import { V as VFX_RENDERING_GROUP_ID } from "../../chunks/rendering-CV3nhygX.js";
9
9
  const SHADER_NAME = "vfxEnergyShield";
10
+ const HALO_ALPHA_SCALE_MAX = 0.3;
11
+ const HALO_FEATHER_ALPHA_RATIO = 0.72;
12
+ const HALO_FEATHER_SCALE_OFFSET = 0.018;
10
13
  const DEFAULT_PARAMS = {
11
14
  radius: 3.18,
12
15
  verticalScale: 1.02,
@@ -25,7 +28,7 @@ const DEFAULT_PARAMS = {
25
28
  flowIntensity: 1.15,
26
29
  marbleContrast: 0.67,
27
30
  bloomIntensity: 0.45,
28
- haloScale: 1.012,
31
+ haloScale: 1.018,
29
32
  showHalo: false,
30
33
  segments: 64
31
34
  };
@@ -146,7 +149,8 @@ void main(void) {
146
149
  vec3 normalW = normalize(vNormalW);
147
150
  vec3 viewDir = normalize(cameraPosition - vWorldPos);
148
151
  float ndv = saturate(dot(normalW, viewDir));
149
- float fresnel = pow(1.0 - ndv, max(0.05, uFresnelPower)) * uFresnelStrength;
152
+ float viewRim = saturate(1.0 - ndv);
153
+ float fresnel = pow(viewRim, max(0.05, uFresnelPower)) * uFresnelStrength;
150
154
 
151
155
  float t = uTime * uFlowSpeed;
152
156
  vec3 spherePos = normalize(vObjPos + vec3(0.0001)) * max(0.05, uNoiseScale);
@@ -175,12 +179,25 @@ void main(void) {
175
179
  alpha += fresnel * uOpacity * 0.58;
176
180
  alpha += noiseEdge * uNoiseEdgeIntensity * 0.05;
177
181
 
178
- if (uHaloMode > 0.5) {
179
- float haloRim = pow(saturate(1.0 - ndv), max(0.35, uFresnelPower * 0.55));
180
- float softRim = smoothstep(0.08, 0.86, haloRim);
181
- float edgeTaper = 1.0 - smoothstep(0.78, 1.0, haloRim) * 0.62;
182
- color = uColor * (0.82 + softRim * 0.32) * (0.9 + cloud * 0.1);
183
- alpha = (0.06 + softRim * 0.62 + noiseEdge * 0.12) * edgeTaper;
182
+ if (uHaloMode > 1.5) {
183
+ float haloRim = pow(viewRim, max(0.28, uFresnelPower * 0.44));
184
+ float featherIn = smoothstep(0.1, 0.46, haloRim);
185
+ float featherOut = 1.0 - smoothstep(0.72, 1.0, haloRim);
186
+ float featherRim = featherIn * featherOut;
187
+ float outerFade = 1.0 - smoothstep(0.88, 1.0, haloRim);
188
+ color = uColor * (0.2 + featherRim * 0.24 + cloud * 0.04);
189
+ alpha = featherRim * 0.58 * outerFade;
190
+ } else if (uHaloMode > 0.5) {
191
+ float haloRim = pow(viewRim, max(0.3, uFresnelPower * 0.48));
192
+ float bodyIn = smoothstep(0.34, 0.68, haloRim);
193
+ float bodyOut = 1.0 - smoothstep(0.72, 1.0, haloRim);
194
+ float bodyRim = bodyIn * bodyOut;
195
+ float featherIn = smoothstep(0.12, 0.44, haloRim);
196
+ float featherOut = 1.0 - smoothstep(0.48, 1.0, haloRim);
197
+ float featherRim = featherIn * featherOut;
198
+ float outerFade = 1.0 - smoothstep(0.86, 1.0, haloRim);
199
+ color = uColor * (0.24 + bodyRim * 0.34 + featherRim * 0.12 + cloud * 0.05);
200
+ alpha = (bodyRim * 0.5 + featherRim * 0.14 + noiseEdge * 0.01) * outerFade;
184
201
  }
185
202
 
186
203
  alpha *= uHaloAlphaScale;
@@ -254,47 +271,78 @@ function createEnergyShield(scene, options = {}) {
254
271
  shield.material = material;
255
272
  let halo = null;
256
273
  let haloMaterial = null;
274
+ let haloFeather = null;
275
+ let haloFeatherMaterial = null;
257
276
  const disposeHalo = () => {
258
277
  halo?.dispose(false, false);
259
278
  haloMaterial?.dispose();
279
+ haloFeather?.dispose(false, false);
280
+ haloFeatherMaterial?.dispose();
260
281
  halo = null;
261
282
  haloMaterial = null;
283
+ haloFeather = null;
284
+ haloFeatherMaterial = null;
262
285
  };
263
286
  const ensureHalo = () => {
264
287
  if (!params.showHalo || params.bloomIntensity <= 1e-3) {
265
288
  disposeHalo();
266
289
  return;
267
290
  }
268
- if (halo && haloMaterial) return;
269
- halo = MeshBuilder.CreateSphere(
270
- "vfx_energyShield_halo",
271
- {
272
- diameter: 2,
273
- segments: Math.max(16, Math.round(params.segments)),
274
- slice: 0.5,
275
- updatable: false
276
- },
277
- scene
278
- );
279
- halo.parent = shield;
280
- halo.isPickable = false;
281
- halo.alwaysSelectAsActiveMesh = true;
282
- halo.renderingGroupId = shield.renderingGroupId;
283
- halo.alphaIndex = shield.alphaIndex + 1;
284
- halo.scaling.setAll(Math.max(1, params.haloScale));
285
- haloMaterial = createShieldMaterial(scene, {
286
- name: "vfx_energyShield_halo_material",
287
- alphaMode: Constants.ALPHA_ADD
288
- });
289
- halo.material = haloMaterial;
291
+ if (!halo || !haloMaterial) {
292
+ halo = MeshBuilder.CreateSphere(
293
+ "vfx_energyShield_halo",
294
+ {
295
+ diameter: 2,
296
+ segments: Math.max(16, Math.round(params.segments)),
297
+ slice: 0.5,
298
+ updatable: false
299
+ },
300
+ scene
301
+ );
302
+ halo.parent = shield;
303
+ halo.isPickable = false;
304
+ halo.alwaysSelectAsActiveMesh = true;
305
+ halo.renderingGroupId = shield.renderingGroupId;
306
+ halo.alphaIndex = shield.alphaIndex + 1;
307
+ halo.scaling.setAll(Math.max(1, params.haloScale));
308
+ haloMaterial = createShieldMaterial(scene, {
309
+ name: "vfx_energyShield_halo_material",
310
+ alphaMode: Constants.ALPHA_ADD
311
+ });
312
+ halo.material = haloMaterial;
313
+ }
314
+ if (!haloFeather || !haloFeatherMaterial) {
315
+ haloFeather = MeshBuilder.CreateSphere(
316
+ "vfx_energyShield_halo_feather",
317
+ {
318
+ diameter: 2,
319
+ segments: Math.max(16, Math.round(params.segments)),
320
+ slice: 0.5,
321
+ updatable: false
322
+ },
323
+ scene
324
+ );
325
+ haloFeather.parent = shield;
326
+ haloFeather.isPickable = false;
327
+ haloFeather.alwaysSelectAsActiveMesh = true;
328
+ haloFeather.renderingGroupId = shield.renderingGroupId;
329
+ haloFeather.alphaIndex = shield.alphaIndex + 2;
330
+ haloFeather.scaling.setAll(getHaloFeatherScale(params));
331
+ haloFeatherMaterial = createShieldMaterial(scene, {
332
+ name: "vfx_energyShield_halo_feather_material",
333
+ alphaMode: Constants.ALPHA_ADD
334
+ });
335
+ haloFeather.material = haloFeatherMaterial;
336
+ }
290
337
  };
291
338
  ensureHalo();
292
- applyParams(material, shield, halo, haloMaterial, params);
339
+ applyParams(material, shield, halo, haloMaterial, haloFeather, haloFeatherMaterial, params);
293
340
  let elapsed = 0;
294
341
  const observer = scene.onBeforeRenderObservable.add(() => {
295
342
  elapsed += Math.min(scene.getEngine().getDeltaTime() / 1e3, 0.05);
296
343
  material.setFloat("uTime", elapsed);
297
344
  haloMaterial?.setFloat("uTime", elapsed);
345
+ haloFeatherMaterial?.setFloat("uTime", elapsed);
298
346
  });
299
347
  let disposed = false;
300
348
  const dispose = () => {
@@ -313,7 +361,7 @@ function createEnergyShield(scene, options = {}) {
313
361
  update(partial) {
314
362
  params = mergeParams(partial, params);
315
363
  ensureHalo();
316
- applyParams(material, shield, halo, haloMaterial, params);
364
+ applyParams(material, shield, halo, haloMaterial, haloFeather, haloFeatherMaterial, params);
317
365
  },
318
366
  dispose
319
367
  };
@@ -356,19 +404,30 @@ function createShieldMaterial(scene, options = {}) {
356
404
  material.fogEnabled = false;
357
405
  return material;
358
406
  }
359
- function applyParams(material, shield, halo, haloMaterial, params) {
407
+ function applyParams(material, shield, halo, haloMaterial, haloFeather, haloFeatherMaterial, params) {
360
408
  shield.scaling.set(params.radius, params.radius * params.verticalScale, params.radius);
361
409
  applyMaterialParams(material, params, params.opacity, 1, 0);
410
+ const haloAlphaScale = Math.min(HALO_ALPHA_SCALE_MAX, params.opacity * Math.min(1, params.bloomIntensity));
362
411
  if (halo && haloMaterial) {
363
412
  halo.scaling.setAll(Math.max(1, params.haloScale));
364
413
  applyMaterialParams(
365
414
  haloMaterial,
366
415
  params,
367
416
  1,
368
- params.opacity * 0.95 * Math.min(2.2, params.bloomIntensity),
417
+ haloAlphaScale,
369
418
  1
370
419
  );
371
420
  }
421
+ if (haloFeather && haloFeatherMaterial) {
422
+ haloFeather.scaling.setAll(getHaloFeatherScale(params));
423
+ applyMaterialParams(
424
+ haloFeatherMaterial,
425
+ params,
426
+ 1,
427
+ haloAlphaScale * HALO_FEATHER_ALPHA_RATIO,
428
+ 2
429
+ );
430
+ }
372
431
  }
373
432
  function applyMaterialParams(material, params, opacity, haloAlphaScale, haloMode) {
374
433
  material.setColor3("uColor", toColor3(params.color));
@@ -398,6 +457,9 @@ function mergeParams(patch, base = DEFAULT_PARAMS) {
398
457
  function toColor3(color) {
399
458
  return new Color3(color.r, color.g, color.b);
400
459
  }
460
+ function getHaloFeatherScale(params) {
461
+ return Math.max(1, params.haloScale + HALO_FEATHER_SCALE_OFFSET);
462
+ }
401
463
  function registerShaders() {
402
464
  Effect.ShadersStore[`${SHADER_NAME}VertexShader`] = VERTEX_SHADER;
403
465
  Effect.ShadersStore[`${SHADER_NAME}FragmentShader`] = FRAGMENT_SHADER;
@@ -3,6 +3,7 @@ interface WeaponTrailParams extends VfxParamValues {
3
3
  lifetimeMode: string;
4
4
  lifetime: number;
5
5
  playbackMode: string;
6
+ historySpace: string;
6
7
  orientation: string;
7
8
  showGlow: boolean;
8
9
  showOuter: boolean;
@@ -12,6 +12,7 @@ const trailTextureUrl = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAP4AAABNC
12
12
  const DEFAULT_PARAMS = {
13
13
  ...LIFETIME_DEFAULTS,
14
14
  playbackMode: "follow",
15
+ historySpace: "world",
15
16
  orientation: "horizontal",
16
17
  showGlow: true,
17
18
  showOuter: true,
@@ -101,6 +102,7 @@ const weaponTrailEffectPackage = {
101
102
  nameZh: "武器拖尾",
102
103
  placement: "socket",
103
104
  geometrySpace: "world",
105
+ optionalInputs: ["reference"],
104
106
  debugAnchor: "player",
105
107
  defaultParams: DEFAULT_PARAMS,
106
108
  debugParams: [
@@ -109,6 +111,10 @@ const weaponTrailEffectPackage = {
109
111
  { value: "follow", label: "跟随挂点" },
110
112
  { value: "orbit", label: "环绕预览" }
111
113
  ] },
114
+ { key: "historySpace", label: "历史空间", kind: "enum", options: [
115
+ { value: "world", label: "世界坐标" },
116
+ { value: "reference", label: "参考点相对" }
117
+ ] },
112
118
  { key: "orientation", label: "拖尾朝向", kind: "enum", options: [
113
119
  { value: "horizontal", label: "水平面" },
114
120
  { value: "vertical", label: "竖直" }
@@ -134,7 +140,7 @@ const weaponTrailEffectPackage = {
134
140
  { key: "flowSpeed", label: "能量流动速度", kind: "number", min: 0, max: 4, step: 0.05 },
135
141
  { key: "tint", label: "颜色", kind: "color" }
136
142
  ],
137
- create({ scene, root, position, params, renderingGroupId }) {
143
+ create({ scene, root, position, params, renderingGroupId, inputs }) {
138
144
  root.position.copyFrom(position);
139
145
  const rgid = renderingGroupId ?? 0;
140
146
  registerShaders();
@@ -199,6 +205,10 @@ const weaponTrailEffectPackage = {
199
205
  let outerInit = false, coreInit = false, glowInit = false;
200
206
  const cur = sample();
201
207
  const mountPos = new Vector3();
208
+ const referencePos = new Vector3();
209
+ const sampleWorldPos = new Vector3();
210
+ let usingReferenceHistory = false;
211
+ let warnedMissingReference = false;
202
212
  const lastWidthAxis = new Vector3(0, 0, 1);
203
213
  const tmpDir = new Vector3();
204
214
  const tailDir = new Vector3(1, 0, 0);
@@ -208,6 +218,32 @@ const weaponTrailEffectPackage = {
208
218
  if (params.playbackMode === "follow" || params.playbackMode === "orbit") return params.playbackMode;
209
219
  return params.orbitRadius > 0 ? "orbit" : "follow";
210
220
  }
221
+ function resolveHistorySpace() {
222
+ return params.historySpace === "reference" ? "reference" : "world";
223
+ }
224
+ function updateReferencePosition() {
225
+ if (resolveHistorySpace() !== "reference") return false;
226
+ const provider = inputs?.reference;
227
+ if (!provider) {
228
+ if (!warnedMissingReference) {
229
+ console.warn('[weapon-trail] historySpace="reference" 需要 inputs.reference; 已回退到世界坐标历史。');
230
+ warnedMissingReference = true;
231
+ }
232
+ return false;
233
+ }
234
+ const value = provider();
235
+ if (!value) return false;
236
+ referencePos.copyFrom(value);
237
+ return true;
238
+ }
239
+ function clearHistory() {
240
+ rawCount = 0;
241
+ for (let i = 0; i < targetCount; i++) {
242
+ points[i].position.set(0, 0, 0);
243
+ points[i].widthAxis.copyFrom(lastWidthAxis);
244
+ pointPathT[i] = 0;
245
+ }
246
+ }
211
247
  function sampleMinDistance() {
212
248
  return Math.max(3e-3, Math.min(0.04, params.worldLength / Math.max(16, targetCount * 3)));
213
249
  }
@@ -262,15 +298,24 @@ const weaponTrailEffectPackage = {
262
298
  rawCount += 1;
263
299
  trimRawHistory();
264
300
  }
265
- function sampleEmitter(out) {
301
+ function sampleEmitter(out, useReference) {
266
302
  const playbackMode = resolvePlaybackMode();
267
303
  const orbitRadius = playbackMode === "orbit" ? params.orbitRadius : 0;
268
304
  const c = Math.cos(angle), s = Math.sin(angle);
269
- out.position.set(
305
+ sampleWorldPos.set(
270
306
  mountPos.x + c * orbitRadius,
271
307
  mountPos.y + params.height,
272
308
  mountPos.z + s * orbitRadius
273
309
  );
310
+ if (useReference) {
311
+ out.position.set(
312
+ sampleWorldPos.x - referencePos.x,
313
+ sampleWorldPos.y - referencePos.y,
314
+ sampleWorldPos.z - referencePos.z
315
+ );
316
+ } else {
317
+ out.position.copyFrom(sampleWorldPos);
318
+ }
274
319
  if (params.orientation === "vertical") {
275
320
  out.widthAxis.set(0, 1, 0);
276
321
  return;
@@ -330,7 +375,7 @@ const weaponTrailEffectPackage = {
330
375
  if (!found) extrapolateTailSample(targetDist, distBefore, t);
331
376
  }
332
377
  }
333
- function buildRibbon(mesh, halfWidth, wasInit) {
378
+ function buildRibbon(mesh, halfWidth, wasInit, useReference) {
334
379
  positions.length = 0;
335
380
  uvs.length = 0;
336
381
  colors.length = 0;
@@ -343,7 +388,10 @@ const weaponTrailEffectPackage = {
343
388
  const tailAlpha = Math.max(0, 1 - Math.pow(pathT, params.tailFadeExponent) * params.tailFadeStrength);
344
389
  const alpha = tailAlpha;
345
390
  const ox = s.widthAxis.x * halfWidth, oy = s.widthAxis.y * halfWidth, oz = s.widthAxis.z * halfWidth;
346
- positions.push(s.position.x + ox, s.position.y + oy, s.position.z + oz, s.position.x - ox, s.position.y - oy, s.position.z - oz);
391
+ const px = useReference ? s.position.x + referencePos.x : s.position.x;
392
+ const py = useReference ? s.position.y + referencePos.y : s.position.y;
393
+ const pz = useReference ? s.position.z + referencePos.z : s.position.z;
394
+ positions.push(px + ox, py + oy, pz + oz, px - ox, py - oy, pz - oz);
347
395
  uvs.push(u, 0, u, 1);
348
396
  colors.push(1, 1, 1, alpha, 1, 1, 1, alpha);
349
397
  if (i < targetCount - 1) {
@@ -373,13 +421,18 @@ const weaponTrailEffectPackage = {
373
421
  if (resolvePlaybackMode() === "orbit") angle += params.orbitSpeed * dt;
374
422
  root.computeWorldMatrix(true);
375
423
  mountPos.copyFrom(root.getAbsolutePosition());
376
- sampleEmitter(cur);
424
+ const useReference = updateReferencePosition();
425
+ if (useReference !== usingReferenceHistory) {
426
+ usingReferenceHistory = useReference;
427
+ clearHistory();
428
+ }
429
+ sampleEmitter(cur, useReference);
377
430
  if (rawCount === 0 || Vector3.Distance(cur.position, raw[0].position) > sampleMinDistance()) insertRawSample(cur);
378
431
  resample();
379
432
  const halfW = params.width * 0.5;
380
- if (glow) glowInit = buildRibbon(glow, halfW * 2.2, glowInit);
381
- if (outer) outerInit = buildRibbon(outer, halfW, outerInit);
382
- if (core) coreInit = buildRibbon(core, halfW * params.coreWidth, coreInit);
433
+ if (glow) glowInit = buildRibbon(glow, halfW * 2.2, glowInit, useReference);
434
+ if (outer) outerInit = buildRibbon(outer, halfW, outerInit, useReference);
435
+ if (core) coreInit = buildRibbon(core, halfW * params.coreWidth, coreInit, useReference);
383
436
  outerMat?.setFloat("uTime", time);
384
437
  coreMat?.setFloat("uTime", time);
385
438
  glowMat?.setFloat("uTime", time);
package/dist/index.js CHANGED
@@ -192,6 +192,7 @@ function normalizeInputs(inputs) {
192
192
  if (inputs.normal != null) out.normal = asFn(inputs.normal);
193
193
  if (inputs.endpoints) out.endpoints = inputs.endpoints.map((e) => asFn(e)).filter(Boolean);
194
194
  if (inputs.target) out.target = inputs.target;
195
+ if (inputs.reference != null) out.reference = asFn(inputs.reference);
195
196
  if (inputs.scalars) {
196
197
  out.scalars = {};
197
198
  for (const [k, v] of Object.entries(inputs.scalars)) {
package/dist/types.d.ts CHANGED
@@ -4,13 +4,14 @@ import { TransformNode } from '@babylonjs/core/Meshes/transformNode';
4
4
  import { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh';
5
5
  export type VfxDebugAnchor = 'playerRight' | 'player';
6
6
  export type VfxInput<T> = T | (() => T);
7
- export type VfxInputKey = 'aim' | 'normal' | 'endpoints' | 'target';
7
+ export type VfxInputKey = 'aim' | 'normal' | 'endpoints' | 'target' | 'reference';
8
8
  /** 调用方提供的 inputs(放在 VfxSpawnTransform.inputs)。 */
9
9
  export interface VfxEffectInputs {
10
10
  aim?: VfxInput<Vector3>;
11
11
  normal?: VfxInput<Vector3>;
12
12
  endpoints?: Array<VfxInput<Vector3>>;
13
13
  target?: AbstractMesh;
14
+ reference?: VfxInput<Vector3>;
14
15
  /** 运行时标量(throttle/intensity 等),值可静态或 provider;特效每帧读取做活改(G5)。 */
15
16
  scalars?: Record<string, VfxInput<number>>;
16
17
  }
@@ -20,6 +21,7 @@ export interface VfxResolvedInputs {
20
21
  normal?: () => Vector3;
21
22
  endpoints?: Array<() => Vector3>;
22
23
  target?: AbstractMesh;
24
+ reference?: () => Vector3;
23
25
  scalars?: Record<string, () => number>;
24
26
  }
25
27
  export type VfxParamValue = number | string | boolean | VfxColorValue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fps-games/vfx",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "type": "module",
5
5
  "description": "Shared VFX library for Playable projects: engine-injected effect packages + runtime. Project-pure (no imports of host project code).",
6
6
  "publishConfig": {
@@ -3,17 +3,13 @@
3
3
  "__generation.offsetY": 0,
4
4
  "__generation.offsetZ": 0,
5
5
  "lifetime": 1,
6
- "scale": 2,
7
- "initialHeight": 0.05,
8
6
  "color": {
9
7
  "r": 1,
10
8
  "g": 0.8392156862745098,
11
9
  "b": 0.12156862745098039
12
10
  },
13
11
  "opacity": 1,
14
- "scaleX": 2,
15
- "scaleY": 0.2,
16
- "scaleZ": 2,
12
+ "footprintShape": "rectangle",
17
13
  "centerCount": 100,
18
14
  "outlineCount": 300,
19
15
  "strandWidth": 0.1,
@@ -23,7 +23,7 @@
23
23
  "flowIntensity": 1.15,
24
24
  "marbleContrast": 0.67,
25
25
  "bloomIntensity": 0.45,
26
- "haloScale": 1.012,
26
+ "haloScale": 1.018,
27
27
  "showHalo": false,
28
28
  "segments": 64
29
29
  }