sparcoon 0.3.1 → 0.4.1

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.
@@ -7,6 +7,7 @@ export declare const EMITTER_DEFAULT_CAPACITY_STEP = 32;
7
7
  export declare const EMITTER_DEFAULT_AUTOMATICALLY_DESTROY_MODULES = true;
8
8
  export declare const EMITTER_DEFAULT_CAST_SHADOW = false;
9
9
  export declare const EMITTER_DEFAULT_RECEIVE_SHADOW = false;
10
+ export declare const EMITTER_DEFAULT_SORT_FRACTION: number;
10
11
  export declare function collectProperties(keeper: Record<string, GLTypeInfo>, modules: readonly {
11
12
  requiredProperties?: Record<string, FXPropertyName>;
12
13
  }[], debugContext: string): void;
@@ -5,6 +5,7 @@ const EMITTER_DEFAULT_CAPACITY_STEP = 32;
5
5
  const EMITTER_DEFAULT_AUTOMATICALLY_DESTROY_MODULES = true;
6
6
  const EMITTER_DEFAULT_CAST_SHADOW = false;
7
7
  const EMITTER_DEFAULT_RECEIVE_SHADOW = false;
8
+ const EMITTER_DEFAULT_SORT_FRACTION = 1 / 10;
8
9
  function collectProperties(keeper, modules, debugContext) {
9
10
  for (const module of modules) {
10
11
  if (module.requiredProperties === undefined) {
@@ -23,4 +24,4 @@ function collectProperties(keeper, modules, debugContext) {
23
24
  }
24
25
  }
25
26
 
26
- export { EMITTER_DEFAULT_AUTOMATICALLY_DESTROY_MODULES, EMITTER_DEFAULT_CAPACITY_STEP, EMITTER_DEFAULT_CAST_SHADOW, EMITTER_DEFAULT_EXPECTED_CAPACITY, EMITTER_DEFAULT_RECEIVE_SHADOW, collectProperties };
27
+ export { EMITTER_DEFAULT_AUTOMATICALLY_DESTROY_MODULES, EMITTER_DEFAULT_CAPACITY_STEP, EMITTER_DEFAULT_CAST_SHADOW, EMITTER_DEFAULT_EXPECTED_CAPACITY, EMITTER_DEFAULT_RECEIVE_SHADOW, EMITTER_DEFAULT_SORT_FRACTION, collectProperties };
@@ -1,3 +1,4 @@
1
+ import type { Camera } from "three";
1
2
  import { Object3D } from "three";
2
3
  import type { FXMaterial } from "../materials/FXMaterial/FXMaterial";
3
4
  import type { FXBehavior } from "./behavior/FXBehavior";
@@ -9,17 +10,23 @@ export interface FXEmitterOptions {
9
10
  automaticallyDestroyModules: boolean;
10
11
  castShadow: boolean;
11
12
  receiveShadow: boolean;
13
+ sortCamera: Camera;
14
+ sortFraction: number;
12
15
  }
13
16
  export declare class FXEmitter extends Object3D {
14
17
  private readonly spawnSequence;
15
18
  private readonly behaviorSequence;
16
19
  automaticallyDestroyModules: boolean;
20
+ sortCamera: Camera | undefined;
21
+ sortFraction: number;
17
22
  private readonly mesh;
18
23
  private readonly material;
19
24
  private emissionRate;
20
25
  private emissionAccumulator;
21
26
  private emissionDuration;
22
27
  private emissionElapsed;
28
+ private sortingAccumulator;
29
+ private readonly sortingCameraWorldPosition;
23
30
  constructor(spawnSequence: FXSpawn[], behaviorSequence: readonly FXBehavior[], material: FXMaterial, options?: Partial<FXEmitterOptions>);
24
31
  static onWillRender: (deltaTime: number) => void;
25
32
  destroy(): void;
@@ -1,8 +1,8 @@
1
- import { Object3D } from 'three';
1
+ import { Object3D, Vector3 } from 'three';
2
2
  import { FXInstancedParticle } from '../instancedParticle/FXInstancedParticle.js';
3
3
  import { resolveGLSLTypeInfo } from '../instancedParticle/shared.js';
4
4
  import { BUILTIN_OFFSET_AGE, BUILTIN_OFFSET_POSITION_X, BUILTIN_OFFSET_VELOCITY_X, BUILTIN_OFFSET_POSITION_Y, BUILTIN_OFFSET_VELOCITY_Y, BUILTIN_OFFSET_POSITION_Z, BUILTIN_OFFSET_VELOCITY_Z, BUILTIN_OFFSET_ROTATION, BUILTIN_OFFSET_TORQUE, BUILTIN_OFFSET_RANDOM_A, BUILTIN_OFFSET_RANDOM_B, BUILTIN_OFFSET_RANDOM_C } from '../miscellaneous/miscellaneous.js';
5
- import { collectProperties, EMITTER_DEFAULT_EXPECTED_CAPACITY, EMITTER_DEFAULT_CAPACITY_STEP, EMITTER_DEFAULT_CAST_SHADOW, EMITTER_DEFAULT_RECEIVE_SHADOW, EMITTER_DEFAULT_AUTOMATICALLY_DESTROY_MODULES } from './FXEmitter.Internal.js';
5
+ import { collectProperties, EMITTER_DEFAULT_EXPECTED_CAPACITY, EMITTER_DEFAULT_CAPACITY_STEP, EMITTER_DEFAULT_CAST_SHADOW, EMITTER_DEFAULT_RECEIVE_SHADOW, EMITTER_DEFAULT_AUTOMATICALLY_DESTROY_MODULES, EMITTER_DEFAULT_SORT_FRACTION } from './FXEmitter.Internal.js';
6
6
 
7
7
  const EMITTERS = new Array();
8
8
  class FXEmitter extends Object3D {
@@ -14,6 +14,8 @@ class FXEmitter extends Object3D {
14
14
  this._private_emissionAccumulator = 0;
15
15
  this._private_emissionDuration = Infinity;
16
16
  this._private_emissionElapsed = 0;
17
+ this._private_sortingAccumulator = 0;
18
+ this._private_sortingCameraWorldPosition = new Vector3();
17
19
  this._private_onRendering = (deltaTime) => {
18
20
  {
19
21
  const { builtin } = this._private_mesh.propertyBuffers;
@@ -69,6 +71,14 @@ class FXEmitter extends Object3D {
69
71
  }
70
72
  builtin.needsUpdate = true;
71
73
  }
74
+ if (this.sortCamera !== undefined) {
75
+ this._private_sortingAccumulator += this.sortFraction;
76
+ if (this._private_sortingAccumulator >= 1) {
77
+ this._private_sortingAccumulator -= 1;
78
+ this.sortCamera.getWorldPosition(this._private_sortingCameraWorldPosition);
79
+ this._private_mesh.sortByDistance(this._private_sortingCameraWorldPosition);
80
+ }
81
+ }
72
82
  };
73
83
  const collectedProperties = {
74
84
  builtin: resolveGLSLTypeInfo("Matrix4"),
@@ -80,12 +90,15 @@ class FXEmitter extends Object3D {
80
90
  if (options.castShadow ?? EMITTER_DEFAULT_CAST_SHADOW) {
81
91
  this._private_mesh.castShadow = true;
82
92
  this._private_mesh.customDepthMaterial = material.buildDepthMaterial();
93
+ this._private_mesh.customDistanceMaterial = material.buildDistanceMaterial();
83
94
  }
84
95
  if (options.receiveShadow ?? EMITTER_DEFAULT_RECEIVE_SHADOW) {
85
96
  this._private_mesh.receiveShadow = true;
86
97
  }
87
98
  this.automaticallyDestroyModules =
88
99
  options.automaticallyDestroyModules ?? EMITTER_DEFAULT_AUTOMATICALLY_DESTROY_MODULES;
100
+ this.sortCamera = options.sortCamera;
101
+ this.sortFraction = options.sortFraction ?? EMITTER_DEFAULT_SORT_FRACTION;
89
102
  this._private_material = material;
90
103
  this.add(this._private_mesh);
91
104
  EMITTERS.push(this);
@@ -1,4 +1,4 @@
1
- import type { Material } from "three";
1
+ import type { Material, Vector3 } from "three";
2
2
  import { InstancedBufferAttribute, Mesh } from "three";
3
3
  import type { GLTypeInfo } from "./shared";
4
4
  export declare class FXInstancedParticle extends Mesh {
@@ -7,10 +7,14 @@ export declare class FXInstancedParticle extends Mesh {
7
7
  private readonly instancedGeometry;
8
8
  private readonly particleMaterial;
9
9
  private capacity;
10
+ private sortingIndices;
11
+ private sortingSquaredDistances;
12
+ private sortingTemporaryBuffer;
10
13
  constructor(varyings: Record<string, GLTypeInfo>, expectedCapacity: number, capacityStep: number, material: Material);
11
14
  get instanceCount(): number;
12
15
  createInstances(count: number): void;
13
16
  removeDeadParticles(): void;
17
+ sortByDistance(cameraWorldPosition: Vector3): void;
14
18
  drop(): void;
15
19
  destroy(): void;
16
20
  private ensureCapacity;
@@ -1,5 +1,5 @@
1
1
  import { Mesh, InstancedBufferAttribute, StreamDrawUsage } from 'three';
2
- import { BUILTIN_OFFSET_AGE, BUILTIN_OFFSET_LIFETIME } from '../miscellaneous/miscellaneous.js';
2
+ import { BUILTIN_OFFSET_AGE, BUILTIN_OFFSET_LIFETIME, BUILTIN_OFFSET_POSITION_X, BUILTIN_OFFSET_POSITION_Y, BUILTIN_OFFSET_POSITION_Z } from '../miscellaneous/miscellaneous.js';
3
3
  import { INSTANCED_PARTICLE_GEOMETRY } from './FXInstancedParticle.Internal.js';
4
4
 
5
5
  class FXInstancedParticle extends Mesh {
@@ -8,6 +8,9 @@ class FXInstancedParticle extends Mesh {
8
8
  super(instancedGeometry, material);
9
9
  this._private_capacityStep = _private_capacityStep;
10
10
  this.propertyBuffers = {};
11
+ this._private_sortingIndices = new Int32Array(0);
12
+ this._private_sortingSquaredDistances = new Float64Array(0);
13
+ this._private_sortingTemporaryBuffer = new Float32Array(0);
11
14
  this.frustumCulled = false;
12
15
  this._private_instancedGeometry = instancedGeometry;
13
16
  this._private_instancedGeometry.instanceCount = 0;
@@ -37,6 +40,7 @@ class FXInstancedParticle extends Mesh {
37
40
  const { array: builtinArray, itemSize: builtinItemSize } = builtinBuffer;
38
41
  const { instanceCount } = this._private_instancedGeometry;
39
42
  let writeIndex = 0;
43
+ let didCompact = false;
40
44
  for (let readIndex = 0; readIndex < instanceCount; readIndex++) {
41
45
  const offset = readIndex * builtinItemSize;
42
46
  if (builtinArray[offset + BUILTIN_OFFSET_AGE] < builtinArray[offset + BUILTIN_OFFSET_LIFETIME]) {
@@ -47,14 +51,72 @@ class FXInstancedParticle extends Mesh {
47
51
  const srcOffset = readIndex * dataItemSize;
48
52
  const dstOffset = writeIndex * dataItemSize;
49
53
  dataArray.copyWithin(dstOffset, srcOffset, srcOffset + dataItemSize);
50
- attribute.needsUpdate = true;
51
54
  }
55
+ didCompact = true;
52
56
  }
53
57
  writeIndex++;
54
58
  }
55
59
  }
60
+ if (didCompact) {
61
+ for (const name in this.propertyBuffers) {
62
+ this.propertyBuffers[name].needsUpdate = true;
63
+ }
64
+ }
56
65
  this._private_instancedGeometry.instanceCount = writeIndex;
57
66
  }
67
+ sortByDistance(cameraWorldPosition) {
68
+ const { instanceCount } = this._private_instancedGeometry;
69
+ if (instanceCount < 2) {
70
+ return;
71
+ }
72
+ const builtinBuffer = this.propertyBuffers.builtin;
73
+ if (builtinBuffer === undefined) {
74
+ return;
75
+ }
76
+ if (this._private_sortingIndices.length < instanceCount) {
77
+ this._private_sortingIndices = new Int32Array(this._private_capacity);
78
+ this._private_sortingSquaredDistances = new Float64Array(this._private_capacity);
79
+ }
80
+ const { array: builtinArray, itemSize: builtinItemSize } = builtinBuffer;
81
+ for (let particleIndex = 0; particleIndex < instanceCount; particleIndex++) {
82
+ this._private_sortingIndices[particleIndex] = particleIndex;
83
+ const itemOffset = particleIndex * builtinItemSize;
84
+ const dx = builtinArray[itemOffset + BUILTIN_OFFSET_POSITION_X] - cameraWorldPosition.x;
85
+ const dy = builtinArray[itemOffset + BUILTIN_OFFSET_POSITION_Y] - cameraWorldPosition.y;
86
+ const dz = builtinArray[itemOffset + BUILTIN_OFFSET_POSITION_Z] - cameraWorldPosition.z;
87
+ this._private_sortingSquaredDistances[particleIndex] = dx * dx + dy * dy + dz * dz;
88
+ }
89
+ const sortingSquaredDistances = this._private_sortingSquaredDistances;
90
+ const sortingIndices = this._private_sortingIndices.subarray(0, instanceCount);
91
+ sortingIndices.sort((indexA, indexB) => sortingSquaredDistances[indexB] - sortingSquaredDistances[indexA]);
92
+ let maximumItemSize = 0;
93
+ for (const name in this.propertyBuffers) {
94
+ if (this.propertyBuffers[name].itemSize > maximumItemSize) {
95
+ maximumItemSize = this.propertyBuffers[name].itemSize;
96
+ }
97
+ }
98
+ const requiredTemporaryBufferSize = instanceCount * maximumItemSize;
99
+ if (this._private_sortingTemporaryBuffer.length < requiredTemporaryBufferSize) {
100
+ this._private_sortingTemporaryBuffer = new Float32Array(this._private_capacity * maximumItemSize);
101
+ }
102
+ for (const name in this.propertyBuffers) {
103
+ const attribute = this.propertyBuffers[name];
104
+ const { array, itemSize } = attribute;
105
+ for (let newIndex = 0; newIndex < instanceCount; newIndex++) {
106
+ const originalIndex = sortingIndices[newIndex];
107
+ const sourceOffset = originalIndex * itemSize;
108
+ const destinationOffset = newIndex * itemSize;
109
+ for (let componentIndex = 0; componentIndex < itemSize; componentIndex++) {
110
+ this._private_sortingTemporaryBuffer[destinationOffset + componentIndex] =
111
+ array[sourceOffset + componentIndex];
112
+ }
113
+ }
114
+ for (let i = 0; i < instanceCount * itemSize; i++) {
115
+ array[i] = this._private_sortingTemporaryBuffer[i];
116
+ }
117
+ attribute.needsUpdate = true;
118
+ }
119
+ }
58
120
  drop() {
59
121
  this._private_instancedGeometry.instanceCount = 0;
60
122
  }
@@ -68,17 +130,32 @@ class FXInstancedParticle extends Mesh {
68
130
  }
69
131
  const newCapacity = Math.ceil(requiredCapacity / this._private_capacityStep) * this._private_capacityStep;
70
132
  for (const name in this.propertyBuffers) {
71
- const { itemSize, array, usage } = this.propertyBuffers[name];
133
+ const oldAttribute = this.propertyBuffers[name];
134
+ const { itemSize, array, usage } = oldAttribute;
72
135
  const newArray = new Float32Array(newCapacity * itemSize);
73
136
  newArray.set(array);
74
137
  const newAttribute = new InstancedBufferAttribute(newArray, itemSize);
75
138
  newAttribute.setUsage(usage);
76
139
  this._private_instancedGeometry.setAttribute(`a_${name}`, newAttribute);
77
140
  this.propertyBuffers[name] = newAttribute;
141
+ // Detach old typed array to help GC release GPU-side memory sooner.
142
+ // Three.js does not expose dispose() on BufferAttribute directly,
143
+ // but clearing the array reference prevents the old data from lingering.
144
+ oldAttribute.array = null;
78
145
  }
79
146
  this._private_capacity = newCapacity;
80
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- old three.js compatibility fix
81
- this._private_instancedGeometry._maxInstanceCount = newCapacity;
147
+ // Force the renderer to recalculate _maxInstanceCount on the next
148
+ // setupVertexAttributes pass. The new InstancedBufferAttributes will
149
+ // trigger a VAO rebind, at which point _maxInstanceCount is recomputed
150
+ // from the current attribute sizes.
151
+ //
152
+ // _maxInstanceCount is an internal renderer cache field set by
153
+ // WebGLBindingStates.js. It was never promoted to a public API in the
154
+ // r157–r180 range. Deleting it is the standard workaround used across
155
+ // the Three.js ecosystem (see #19706, #26363, #27205).
156
+ //
157
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
158
+ delete this._private_instancedGeometry._maxInstanceCount;
82
159
  }
83
160
  }
84
161
 
@@ -1,7 +1,23 @@
1
1
  import type { Blending } from "three";
2
- import { MeshLambertMaterial } from "three";
2
+ import { Color, MeshLambertMaterial } from "three";
3
3
  import type { GLTypeInfo } from "../../instancedParticle/shared";
4
4
  import type { FXColorNode } from "../../nodes/color/FXColorNode";
5
5
  import type { FXNormalNode } from "../../nodes/normal/FXNormalNode";
6
6
  import { FXTextureNode } from "../../nodes/texture/FXTextureNode";
7
- export declare function buildFXDiffuseMaterial(varyings: Record<string, GLTypeInfo>, blending: Blending, useAlphaHashing: boolean, alphaTest: number, albedoNodes: readonly (FXColorNode | FXTextureNode)[], normalNodes: readonly (FXTextureNode | FXNormalNode)[], emissionNodes: readonly (FXColorNode | FXTextureNode)[]): MeshLambertMaterial;
7
+ export interface FXScatterUniforms {
8
+ u_ScatterTint: {
9
+ value: Color;
10
+ };
11
+ u_ScatterPower: {
12
+ value: number;
13
+ };
14
+ u_ForwardScatterStrength: {
15
+ value: number;
16
+ };
17
+ u_BackScatterStrength: {
18
+ value: number;
19
+ };
20
+ }
21
+ export declare function buildFXDiffuseMaterial(varyings: Record<string, GLTypeInfo>, blending: Blending, useAlphaHashing: boolean, alphaTest: number, premultipliedAlpha: boolean, albedoNodes: readonly (FXColorNode | FXTextureNode)[], normalNodes: readonly (FXTextureNode | FXNormalNode)[], emissionNodes: readonly (FXColorNode | FXTextureNode)[], scatterUniforms?: FXScatterUniforms, shadowSensitivity?: {
22
+ value: number;
23
+ }): MeshLambertMaterial;
@@ -2,7 +2,44 @@ import { MeshLambertMaterial, Color, DoubleSide } from 'three';
2
2
  import { PARTICLE_DEFINES } from '../../miscellaneous/miscellaneous.js';
3
3
  import { FXTextureNode } from '../../nodes/texture/FXTextureNode.js';
4
4
 
5
- function buildFXDiffuseMaterial(varyings, blending, useAlphaHashing, alphaTest, albedoNodes, normalNodes, emissionNodes) {
5
+ /**
6
+ * Wraps every `getShadow(...)` call in the shader source with
7
+ * `mix(1.0, getShadow(...), <uniformName>)` so that a uniform
8
+ * can scale shadow influence from 0 (no shadows) to 1 (full shadows).
9
+ */
10
+ function wrapShadowCalls(source, uniformName) {
11
+ const target = "getShadow(";
12
+ let result = "";
13
+ let position = 0;
14
+ while (position < source.length) {
15
+ const callStart = source.indexOf(target, position);
16
+ if (callStart === -1) {
17
+ result += source.slice(position);
18
+ break;
19
+ }
20
+ result += source.slice(position, callStart);
21
+ let depth = 0;
22
+ let cursor = callStart + target.length - 1;
23
+ for (; cursor < source.length; cursor++) {
24
+ if (source[cursor] === "(") {
25
+ depth++;
26
+ }
27
+ if (source[cursor] === ")") {
28
+ depth--;
29
+ }
30
+ if (depth === 0) {
31
+ break;
32
+ }
33
+ }
34
+ const originalCall = source.slice(callStart, cursor + 1);
35
+ result += `mix(1.0, ${originalCall}, ${uniformName})`;
36
+ position = cursor + 1;
37
+ }
38
+ return result;
39
+ }
40
+ function buildFXDiffuseMaterial(varyings, blending, useAlphaHashing, alphaTest, premultipliedAlpha, albedoNodes, normalNodes, emissionNodes, scatterUniforms, shadowSensitivity) {
41
+ const useScatter = scatterUniforms !== undefined;
42
+ const useShadowSensitivity = shadowSensitivity !== undefined;
6
43
  const attributeDeclarations = [];
7
44
  const varyingDeclarations = [];
8
45
  const vertexAssignments = [];
@@ -13,6 +50,7 @@ function buildFXDiffuseMaterial(varyings, blending, useAlphaHashing, alphaTest,
13
50
  vertexAssignments.push(`p_${name} = a_${name};`);
14
51
  }
15
52
  const material = new MeshLambertMaterial({
53
+ name: "FXDiffuseMaterial",
16
54
  transparent: !useAlphaHashing,
17
55
  depthWrite: useAlphaHashing,
18
56
  depthTest: true,
@@ -22,12 +60,14 @@ function buildFXDiffuseMaterial(varyings, blending, useAlphaHashing, alphaTest,
22
60
  forceSinglePass: true,
23
61
  alphaTest,
24
62
  emissive: emissionNodes.length > 0 ? new Color(1, 1, 1) : new Color(0, 0, 0),
63
+ premultipliedAlpha,
25
64
  });
26
65
  material.onBeforeCompile = (shader) => {
27
66
  const vertexPreamble = [
28
67
  PARTICLE_DEFINES,
29
68
  attributeDeclarations.join("\n"),
30
69
  "varying vec2 p_uv;",
70
+ useScatter ? "varying vec3 v_viewPosition;" : "",
31
71
  varyingDeclarations.join("\n"),
32
72
  ].join("\n");
33
73
  shader.vertexShader =
@@ -44,29 +84,26 @@ function buildFXDiffuseMaterial(varyings, blending, useAlphaHashing, alphaTest,
44
84
  " vPosition = vec3(PARTICLE_POSITION_X, PARTICLE_POSITION_Y, PARTICLE_POSITION_Z) + vec3(position.xy, 0.0);",
45
85
  "#endif",
46
86
  ].join("\n"))
47
- .replace("#include <project_vertex>", `
48
- // Scale and rotate the billboard offset in camera space
49
- vec2 billboardOffset = position.xy;
50
- billboardOffset.x *= PARTICLE_SCALE_X;
51
- billboardOffset.y *= PARTICLE_SCALE_Y;
52
-
53
- float cosRotation = cos(PARTICLE_ROTATION);
54
- float sinRotation = sin(PARTICLE_ROTATION);
55
- billboardOffset = vec2(
56
- billboardOffset.x * cosRotation - billboardOffset.y * sinRotation,
57
- billboardOffset.x * sinRotation + billboardOffset.y * cosRotation
58
- );
59
-
60
- // Move particle center to view space, then apply the billboard offset
61
- vec3 particleCenter = vec3(PARTICLE_POSITION_X, PARTICLE_POSITION_Y, PARTICLE_POSITION_Z);
62
- vec4 mvPosition = modelViewMatrix * vec4(particleCenter, 1.0);
63
- mvPosition.xy += billboardOffset;
64
- gl_Position = projectionMatrix * mvPosition;
65
- `)
87
+ .replace("#include <project_vertex>", [
88
+ "vec2 billboardOffset = position.xy;",
89
+ "billboardOffset.x *= PARTICLE_SCALE_X;",
90
+ "billboardOffset.y *= PARTICLE_SCALE_Y;",
91
+ "",
92
+ "float cosRotation = cos(PARTICLE_ROTATION);",
93
+ "float sinRotation = sin(PARTICLE_ROTATION);",
94
+ "billboardOffset = vec2(",
95
+ " billboardOffset.x * cosRotation - billboardOffset.y * sinRotation,",
96
+ " billboardOffset.x * sinRotation + billboardOffset.y * cosRotation",
97
+ ");",
98
+ "",
99
+ "vec3 particleCenter = vec3(PARTICLE_POSITION_X, PARTICLE_POSITION_Y, PARTICLE_POSITION_Z);",
100
+ "vec4 mvPosition = modelViewMatrix * vec4(particleCenter, 1.0);",
101
+ "mvPosition.xy += billboardOffset;",
102
+ useScatter ? "v_viewPosition = mvPosition.xyz;" : "",
103
+ "gl_Position = projectionMatrix * mvPosition;",
104
+ ].join("\n"))
66
105
  .replace("#include <worldpos_vertex>", `
67
106
  #if defined(USE_SHADOWMAP) || defined(USE_ENVMAP) || defined(DISTANCE) || (NUM_SPOT_LIGHT_COORDS > 0)
68
- // Reconstruct world-space position for shadow/environment maps.
69
- // Camera right and up are the first two rows of the view matrix rotation (transposed).
70
107
  vec3 cameraRight = vec3(viewMatrix[0][0], viewMatrix[1][0], viewMatrix[2][0]);
71
108
  vec3 cameraUp = vec3(viewMatrix[0][1], viewMatrix[1][1], viewMatrix[2][1]);
72
109
  vec4 worldPosition = vec4(
@@ -95,6 +132,24 @@ function buildFXDiffuseMaterial(varyings, blending, useAlphaHashing, alphaTest,
95
132
  })
96
133
  .map((n) => n.helperFunctions)
97
134
  .join("\n");
135
+ const scatterDeclarations = useScatter
136
+ ? [
137
+ "varying vec3 v_viewPosition;",
138
+ "uniform vec3 u_ScatterTint;",
139
+ "uniform float u_ScatterPower;",
140
+ "uniform float u_ForwardScatterStrength;",
141
+ "uniform float u_BackScatterStrength;",
142
+ `vec3 calculateScatter(vec3 viewDirection, vec3 lightDirection, vec3 lightColor, float alpha) {
143
+ float viewDotLight = dot(viewDirection, lightDirection);
144
+ float scatterForward = pow(max(-viewDotLight, 0.0), u_ScatterPower) * u_ForwardScatterStrength;
145
+ float scatterBackward = pow(max( viewDotLight, 0.0), 2.0) * u_BackScatterStrength;
146
+ return lightColor * u_ScatterTint * (scatterForward + scatterBackward) * alpha;
147
+ }`,
148
+ ]
149
+ : [];
150
+ const shadowSensitivityDeclarations = useShadowSensitivity
151
+ ? ["uniform float u_shadowSensitivity;"]
152
+ : [];
98
153
  const fragmentPreamble = [
99
154
  PARTICLE_DEFINES,
100
155
  "varying vec2 p_uv;",
@@ -106,7 +161,18 @@ function buildFXDiffuseMaterial(varyings, blending, useAlphaHashing, alphaTest,
106
161
  blendReorientedNormalsHelper,
107
162
  emissionNodes.flatMap((n) => n.uniformDeclarations).join("\n"),
108
163
  uniqueHelpers(emissionNodes),
164
+ ...scatterDeclarations,
165
+ ...shadowSensitivityDeclarations,
109
166
  ].join("\n");
167
+ if (useScatter) {
168
+ shader.uniforms["u_ScatterTint"] = scatterUniforms.u_ScatterTint;
169
+ shader.uniforms["u_ScatterPower"] = scatterUniforms.u_ScatterPower;
170
+ shader.uniforms["u_ForwardScatterStrength"] = scatterUniforms.u_ForwardScatterStrength;
171
+ shader.uniforms["u_BackScatterStrength"] = scatterUniforms.u_BackScatterStrength;
172
+ }
173
+ if (useShadowSensitivity) {
174
+ shader.uniforms["u_shadowSensitivity"] = shadowSensitivity;
175
+ }
110
176
  for (const node of albedoNodes) {
111
177
  Object.assign(shader.uniforms, node.uniforms);
112
178
  }
@@ -131,6 +197,7 @@ function buildFXDiffuseMaterial(varyings, blending, useAlphaHashing, alphaTest,
131
197
  "float faceDirection = gl_FrontFacing ? 1.0 : -1.0;",
132
198
  `vec3 normal = ${normalExpressions[0]};`,
133
199
  ...blendLines,
200
+ "normal *= faceDirection;",
134
201
  ].join("\n");
135
202
  fragmentShader = fragmentShader.replace("#include <normal_fragment_begin>", normalCode);
136
203
  }
@@ -143,9 +210,71 @@ function buildFXDiffuseMaterial(varyings, blending, useAlphaHashing, alphaTest,
143
210
  const combinedExpression = emissionNodes.map((n) => n.colorExpression).join(" * ");
144
211
  fragmentShader = fragmentShader.replace("#include <emissivemap_fragment>", `totalEmissiveRadiance = (${combinedExpression}).rgb;`);
145
212
  }
213
+ if (useScatter) {
214
+ // Per-fragment Mie scatter for all light types.
215
+ // viewDirection and all light directions/positions are in view space.
216
+ fragmentShader = fragmentShader.replace("#include <opaque_fragment>", `
217
+ #ifdef OPAQUE
218
+ diffuseColor.a = 1.0;
219
+ #endif
220
+
221
+ vec3 viewDirection = normalize(-v_viewPosition);
222
+ vec3 totalScatter = vec3(0.0);
223
+
224
+ #if NUM_DIR_LIGHTS > 0
225
+ for (int i = 0; i < NUM_DIR_LIGHTS; i++) {
226
+ totalScatter += calculateScatter(viewDirection, directionalLights[i].direction, directionalLights[i].color, diffuseColor.a);
227
+ }
228
+ #endif
229
+
230
+ #if NUM_POINT_LIGHTS > 0
231
+ for (int i = 0; i < NUM_POINT_LIGHTS; i++) {
232
+ vec3 toLightDirection = normalize(pointLights[i].position - v_viewPosition);
233
+ float lightDistance = length(pointLights[i].position - v_viewPosition);
234
+ float attenuation = 1.0 / (1.0 + 0.09 * lightDistance + 0.032 * lightDistance * lightDistance);
235
+ totalScatter += calculateScatter(viewDirection, toLightDirection, pointLights[i].color, diffuseColor.a) * attenuation;
236
+ }
237
+ #endif
238
+
239
+ #if NUM_SPOT_LIGHTS > 0
240
+ for (int i = 0; i < NUM_SPOT_LIGHTS; i++) {
241
+ vec3 toLightDirection = normalize(spotLights[i].position - v_viewPosition);
242
+ float lightDistance = length(spotLights[i].position - v_viewPosition);
243
+ float attenuation = 1.0 / (1.0 + 0.09 * lightDistance + 0.032 * lightDistance * lightDistance);
244
+ float spotCosine = dot(normalize(spotLights[i].direction), -toLightDirection);
245
+ float spotFalloff = smoothstep(spotLights[i].coneCos, spotLights[i].penumbraCos, spotCosine);
246
+ totalScatter += calculateScatter(viewDirection, toLightDirection, spotLights[i].color, diffuseColor.a) * attenuation * spotFalloff;
247
+ }
248
+ #endif
249
+
250
+ // calculateScatter() includes a "* alpha" density factor. Compositing
251
+ // will apply another "* alpha" (via blend equation in straight mode,
252
+ // or via premultiplied_alpha_fragment + One/OneMinusSrcAlpha blend).
253
+ // Without correction, scatter would get alpha² while diffuse lighting
254
+ // only gets alpha¹. Dividing diffuseColor.rgb by alpha cancels the
255
+ // extra factor, so scatter composites at the same alpha power as
256
+ // the rest of outgoingLight.
257
+ vec3 albedo = diffuseColor.a > 0.001
258
+ ? diffuseColor.rgb / diffuseColor.a
259
+ : vec3(0.0);
260
+ gl_FragColor = vec4(outgoingLight + totalScatter * albedo, diffuseColor.a);
261
+ `);
262
+ }
263
+ if (useShadowSensitivity) {
264
+ fragmentShader = wrapShadowCalls(fragmentShader, "u_shadowSensitivity");
265
+ }
146
266
  shader.fragmentShader = fragmentPreamble + "\n" + fragmentShader;
147
267
  };
148
- material.customProgramCacheKey = () => `fx-diffuse-${albedoNodes.map((n) => n.cacheKey).join("-") || "none"}-${normalNodes.map((n) => n.cacheKey).join("-") || "none"}-${emissionNodes.map((n) => n.cacheKey).join("-") || "none"}`;
268
+ const scatterKey = useScatter ? `scatter(${scatterUniforms.u_ScatterPower.value})` : "no-scatter";
269
+ const shadowKey = useShadowSensitivity ? "shadow-sens" : "no-shadow-sens";
270
+ material.customProgramCacheKey = () => [
271
+ "fx",
272
+ albedoNodes.map((n) => n.cacheKey).join("-") || "none",
273
+ normalNodes.map((n) => n.cacheKey).join("-") || "none",
274
+ emissionNodes.map((n) => n.cacheKey).join("-") || "none",
275
+ scatterKey,
276
+ shadowKey,
277
+ ].join("_");
149
278
  return material;
150
279
  }
151
280
 
@@ -1,15 +1,24 @@
1
+ import type { ColorRepresentation } from "three";
1
2
  import type { FXColorNode } from "../../nodes/color/FXColorNode";
2
3
  import type { FXNormalNode } from "../../nodes/normal/FXNormalNode";
3
4
  import type { FXTextureNode } from "../../nodes/texture/FXTextureNode";
4
5
  import type { FXMaterialOptions } from "../FXMaterial/FXMaterial";
5
6
  import { FXMaterial } from "../FXMaterial/FXMaterial";
6
7
  export interface FXDiffuseMaterialOptions extends FXMaterialOptions {
7
- normalNodes?: (FXTextureNode | FXNormalNode)[];
8
- emissionNodes?: (FXColorNode | FXTextureNode)[];
8
+ normalNodes: (FXTextureNode | FXNormalNode)[];
9
+ emissionNodes: (FXColorNode | FXTextureNode)[];
10
+ enableScatter: boolean;
11
+ scatterTint: ColorRepresentation;
12
+ scatterPower: number;
13
+ forwardScatterStrength: number;
14
+ backScatterStrength: number;
15
+ shadowSensitivity: number;
9
16
  }
10
17
  export declare class FXDiffuseMaterial extends FXMaterial {
11
18
  private readonly normalNodes;
12
19
  private readonly emissionNodes;
13
- constructor(options?: FXDiffuseMaterialOptions);
20
+ private readonly scatterUniforms;
21
+ private readonly shadowSensitivity;
22
+ constructor(options?: Partial<FXDiffuseMaterialOptions>);
14
23
  destroy(): void;
15
24
  }
@@ -1,3 +1,4 @@
1
+ import { FXColor } from '../../miscellaneous/color/FXColor.js';
1
2
  import { FXSphericalNormalNode } from '../../nodes/normal/FXSphericalNormalNode.js';
2
3
  import { FXMaterial } from '../FXMaterial/FXMaterial.js';
3
4
  import { buildFXDiffuseMaterial } from './FXDiffuseMaterial.Internal.js';
@@ -7,6 +8,20 @@ class FXDiffuseMaterial extends FXMaterial {
7
8
  super(options);
8
9
  this._private_normalNodes = options.normalNodes ?? [new FXSphericalNormalNode()];
9
10
  this._private_emissionNodes = options.emissionNodes ?? [];
11
+ this._private_scatterUniforms =
12
+ options.enableScatter === true
13
+ ? {
14
+ // eslint-disable-next-line @typescript-eslint/naming-convention
15
+ u_ScatterTint: { value: new FXColor(options.scatterTint ?? 0xffd999).toThreeColor() },
16
+ // eslint-disable-next-line @typescript-eslint/naming-convention
17
+ u_ScatterPower: { value: options.scatterPower ?? 10 },
18
+ // eslint-disable-next-line @typescript-eslint/naming-convention
19
+ u_ForwardScatterStrength: { value: options.forwardScatterStrength ?? 1 },
20
+ // eslint-disable-next-line @typescript-eslint/naming-convention
21
+ u_BackScatterStrength: { value: options.backScatterStrength ?? 1 },
22
+ }
23
+ : undefined;
24
+ this._private_shadowSensitivity = { value: options.shadowSensitivity ?? 1 };
10
25
  }
11
26
  destroy() {
12
27
  super.destroy();
@@ -19,7 +34,7 @@ class FXDiffuseMaterial extends FXMaterial {
19
34
  }
20
35
  /** @internal */
21
36
  buildThreeMaterial(varyings) {
22
- return buildFXDiffuseMaterial(varyings, this.blending, this.useAlphaHashing, this.alphaTest, this.albedoNodes, this._private_normalNodes, this._private_emissionNodes);
37
+ return buildFXDiffuseMaterial(varyings, this.blending, this.useAlphaHashing, this.alphaTest, this.premultipliedAlpha, this.albedoNodes, this._private_normalNodes, this._private_emissionNodes, this._private_scatterUniforms, this._private_shadowSensitivity);
23
38
  }
24
39
  }
25
40
 
@@ -0,0 +1,4 @@
1
+ import { MeshDepthMaterial } from "three";
2
+ import type { FXColorNode } from "../../nodes/color/FXColorNode";
3
+ import type { FXTextureNode } from "../../nodes/texture/FXTextureNode";
4
+ export declare function buildDepthMaterial(albedoNodes: readonly (FXColorNode | FXTextureNode)[], depthAlphaTest: number, useDepthAlphaHash: boolean, useSphericalDepth: boolean): MeshDepthMaterial;
@@ -0,0 +1,139 @@
1
+ import { MeshDepthMaterial, DoubleSide, RGBADepthPacking } from 'three';
2
+ import { PARTICLE_DEFINES } from '../../miscellaneous/miscellaneous.js';
3
+
4
+ const DEPTH_VERTEX_SHADER = `
5
+ #define PARTICLE_POSITION_X a_builtin[0][0]
6
+ #define PARTICLE_POSITION_Y a_builtin[0][1]
7
+ #define PARTICLE_POSITION_Z a_builtin[0][2]
8
+
9
+ #define PARTICLE_SCALE_X a_builtin[1][2]
10
+ #define PARTICLE_SCALE_Y a_builtin[1][3]
11
+
12
+ #define PARTICLE_ROTATION a_builtin[2][1]
13
+
14
+ attribute mat4 a_builtin;
15
+ varying mat4 p_builtin;
16
+ varying vec2 p_uv;
17
+ varying vec2 vHighPrecisionZW;
18
+ varying float vCenterViewZ;
19
+
20
+ #ifdef USE_ALPHAHASH
21
+ varying vec3 vPosition;
22
+ #endif
23
+
24
+ void main() {
25
+ p_builtin = a_builtin;
26
+ p_uv = uv;
27
+
28
+ #ifdef USE_ALPHAHASH
29
+ vPosition = vec3(PARTICLE_POSITION_X, PARTICLE_POSITION_Y, PARTICLE_POSITION_Z) + vec3(position.xy, 0.0);
30
+ #endif
31
+
32
+ vec2 billboardOffset = position.xy;
33
+ billboardOffset.x *= PARTICLE_SCALE_X;
34
+ billboardOffset.y *= PARTICLE_SCALE_Y;
35
+
36
+ float cosRotation = cos(PARTICLE_ROTATION);
37
+ float sinRotation = sin(PARTICLE_ROTATION);
38
+ billboardOffset = vec2(
39
+ billboardOffset.x * cosRotation - billboardOffset.y * sinRotation,
40
+ billboardOffset.x * sinRotation + billboardOffset.y * cosRotation
41
+ );
42
+
43
+ vec3 particleCenter = vec3(PARTICLE_POSITION_X, PARTICLE_POSITION_Y, PARTICLE_POSITION_Z);
44
+ vec4 modelViewPosition = modelViewMatrix * vec4(particleCenter, 1.0);
45
+ vCenterViewZ = modelViewPosition.z;
46
+ modelViewPosition.xy += billboardOffset;
47
+ gl_Position = projectionMatrix * modelViewPosition;
48
+ vHighPrecisionZW = gl_Position.zw;
49
+ }
50
+ `;
51
+ /**
52
+ * GLSL snippet that replaces the flat `fragCoordZ` with a spherical depth
53
+ * approximation. Treats each billboard as the front hemisphere of a sphere
54
+ * whose radius equals half the smaller particle scale dimension. Fragments
55
+ * outside that radius are discarded, producing a circular silhouette.
56
+ *
57
+ * Expects `vCenterViewZ` (view-space Z of the particle center), `p_uv`,
58
+ * `PARTICLE_SCALE_X / Y`, and `projectionMatrix` to be available.
59
+ */
60
+ const SPHERICAL_DEPTH_FRAG_COORD_Z = `
61
+ float sphereOffsetX = (p_uv.x - 0.5) * PARTICLE_SCALE_X;
62
+ float sphereOffsetY = (p_uv.y - 0.5) * PARTICLE_SCALE_Y;
63
+ float sphereRadius = min(PARTICLE_SCALE_X, PARTICLE_SCALE_Y) * 0.5;
64
+ float squaredDistanceFromCenter = sphereOffsetX * sphereOffsetX + sphereOffsetY * sphereOffsetY;
65
+ if (squaredDistanceFromCenter > sphereRadius * sphereRadius) discard;
66
+
67
+ float sphereViewZ = vCenterViewZ + sqrt(sphereRadius * sphereRadius - squaredDistanceFromCenter);
68
+ float projectedZ = projectionMatrix[2][2] * sphereViewZ + projectionMatrix[3][2];
69
+ float projectedW = projectionMatrix[2][3] * sphereViewZ + projectionMatrix[3][3];
70
+ float normalizedDeviceZ = projectedZ / projectedW;
71
+
72
+ #ifdef USE_REVERSEDEPTHBUF
73
+ float fragCoordZ = normalizedDeviceZ;
74
+ #else
75
+ float fragCoordZ = 0.5 * normalizedDeviceZ + 0.5;
76
+ #endif
77
+ `;
78
+ const FRAG_COORD_Z_PATTERN = /float fragCoordZ\b[^;]*;/;
79
+ function buildDepthMaterial(albedoNodes, depthAlphaTest, useDepthAlphaHash, useSphericalDepth) {
80
+ const depthNodes = albedoNodes.filter((node) => node.affectsDepth);
81
+ const needsFragmentPatch = depthNodes.length > 0 || useSphericalDepth;
82
+ const material = new MeshDepthMaterial({
83
+ name: "FXDepthMaterial",
84
+ depthPacking: RGBADepthPacking,
85
+ side: DoubleSide,
86
+ forceSinglePass: true,
87
+ alphaHash: useDepthAlphaHash,
88
+ });
89
+ material.onBeforeCompile = (shader) => {
90
+ shader.vertexShader = DEPTH_VERTEX_SHADER;
91
+ if (!needsFragmentPatch) {
92
+ return;
93
+ }
94
+ const seenCacheKeys = new Set();
95
+ const uniqueHelpers = depthNodes
96
+ .filter((node) => {
97
+ if (seenCacheKeys.has(node.cacheKey)) {
98
+ return false;
99
+ }
100
+ seenCacheKeys.add(node.cacheKey);
101
+ return true;
102
+ })
103
+ .map((node) => node.helperFunctions)
104
+ .join("\n");
105
+ const fragmentPreamble = [
106
+ PARTICLE_DEFINES,
107
+ "varying mat4 p_builtin;",
108
+ "varying vec2 p_uv;",
109
+ "varying float vCenterViewZ;",
110
+ useSphericalDepth ? "uniform mat4 projectionMatrix;" : "",
111
+ depthNodes.flatMap((node) => node.uniformDeclarations).join("\n"),
112
+ uniqueHelpers,
113
+ ].join("\n");
114
+ for (const node of depthNodes) {
115
+ Object.assign(shader.uniforms, node.uniforms);
116
+ }
117
+ let fragmentShader = shader.fragmentShader;
118
+ if (depthNodes.length > 0) {
119
+ const combinedAlphaExpression = depthNodes.map((node) => node.colorExpression).join(" * ");
120
+ fragmentShader = fragmentShader.replace("#include <map_fragment>", `diffuseColor.a = (${combinedAlphaExpression}).a;
121
+ if (diffuseColor.a < ${depthAlphaTest.toFixed(4)}) discard;`);
122
+ }
123
+ if (useSphericalDepth) {
124
+ if (!FRAG_COORD_Z_PATTERN.test(fragmentShader)) {
125
+ console.warn("FXDepthMaterial: could not find 'float fragCoordZ' in the depth fragment shader. " +
126
+ "Spherical depth will not be applied. " +
127
+ "This may indicate a Three.js version mismatch.");
128
+ }
129
+ else {
130
+ fragmentShader = fragmentShader.replace(FRAG_COORD_Z_PATTERN, SPHERICAL_DEPTH_FRAG_COORD_Z);
131
+ }
132
+ }
133
+ shader.fragmentShader = fragmentPreamble + "\n" + fragmentShader;
134
+ };
135
+ material.customProgramCacheKey = () => `fx-depth-${useDepthAlphaHash ? "alphaHash" : "alphaTest"}-${useSphericalDepth ? "spherical" : "flat"}-${depthNodes.map((node) => node.cacheKey).join("-") || "none"}`;
136
+ return material;
137
+ }
138
+
139
+ export { buildDepthMaterial };
@@ -0,0 +1,4 @@
1
+ import { MeshDistanceMaterial } from "three";
2
+ import type { FXColorNode } from "../../nodes/color/FXColorNode";
3
+ import type { FXTextureNode } from "../../nodes/texture/FXTextureNode";
4
+ export declare function buildDistanceMaterial(albedoNodes: readonly (FXColorNode | FXTextureNode)[], distanceAlphaTest: number, useDistanceAlphaHash: boolean, useSphericalDepth: boolean): MeshDistanceMaterial;
@@ -0,0 +1,171 @@
1
+ import { MeshDistanceMaterial, DoubleSide } from 'three';
2
+ import { PARTICLE_DEFINES } from '../../miscellaneous/miscellaneous.js';
3
+
4
+ /**
5
+ * Custom vertex shader for point/spot light shadow maps.
6
+ *
7
+ * Unlike the depth material (which writes clip-space Z for directional light
8
+ * shadows), the distance material writes the world-space distance from each
9
+ * fragment to the light position. This means we need to reconstruct world
10
+ * position for each billboarded vertex.
11
+ *
12
+ * `vWorldPosition` is a Three.js convention consumed by the distance fragment
13
+ * shader. `viewMatrix` here is the shadow camera's view matrix, so the
14
+ * billboard faces the light — which is correct for shadow shape.
15
+ */
16
+ const DISTANCE_VERTEX_SHADER = `
17
+ #define DISTANCE
18
+
19
+ #define PARTICLE_POSITION_X a_builtin[0][0]
20
+ #define PARTICLE_POSITION_Y a_builtin[0][1]
21
+ #define PARTICLE_POSITION_Z a_builtin[0][2]
22
+
23
+ #define PARTICLE_SCALE_X a_builtin[1][2]
24
+ #define PARTICLE_SCALE_Y a_builtin[1][3]
25
+
26
+ #define PARTICLE_ROTATION a_builtin[2][1]
27
+
28
+ attribute mat4 a_builtin;
29
+ varying mat4 p_builtin;
30
+ varying vec2 p_uv;
31
+ varying vec3 vWorldPosition;
32
+
33
+ #ifdef USE_ALPHAHASH
34
+ varying vec3 vPosition;
35
+ #endif
36
+
37
+ void main() {
38
+ p_builtin = a_builtin;
39
+ p_uv = uv;
40
+
41
+ #ifdef USE_ALPHAHASH
42
+ vPosition = vec3(PARTICLE_POSITION_X, PARTICLE_POSITION_Y, PARTICLE_POSITION_Z) + vec3(position.xy, 0.0);
43
+ #endif
44
+
45
+ vec2 billboardOffset = position.xy;
46
+ billboardOffset.x *= PARTICLE_SCALE_X;
47
+ billboardOffset.y *= PARTICLE_SCALE_Y;
48
+
49
+ float cosRotation = cos(PARTICLE_ROTATION);
50
+ float sinRotation = sin(PARTICLE_ROTATION);
51
+ billboardOffset = vec2(
52
+ billboardOffset.x * cosRotation - billboardOffset.y * sinRotation,
53
+ billboardOffset.x * sinRotation + billboardOffset.y * cosRotation
54
+ );
55
+
56
+ vec3 particleCenter = vec3(PARTICLE_POSITION_X, PARTICLE_POSITION_Y, PARTICLE_POSITION_Z);
57
+
58
+ // Reconstruct world-space position for the billboarded vertex.
59
+ // Camera axes are extracted from the shadow camera's view matrix (transposed
60
+ // rotation part), then scaled by the billboard offset.
61
+ vec3 cameraRight = vec3(viewMatrix[0][0], viewMatrix[1][0], viewMatrix[2][0]);
62
+ vec3 cameraUp = vec3(viewMatrix[0][1], viewMatrix[1][1], viewMatrix[2][1]);
63
+ vec3 worldCenter = (modelMatrix * vec4(particleCenter, 1.0)).xyz;
64
+ vWorldPosition = worldCenter + cameraRight * billboardOffset.x + cameraUp * billboardOffset.y;
65
+
66
+ // Standard clip-space projection via the shadow camera.
67
+ vec4 modelViewPosition = modelViewMatrix * vec4(particleCenter, 1.0);
68
+ modelViewPosition.xy += billboardOffset;
69
+ gl_Position = projectionMatrix * modelViewPosition;
70
+ }
71
+ `;
72
+ /**
73
+ * GLSL snippet for spherical distance adjustment.
74
+ *
75
+ * Instead of measuring distance from the flat billboard surface, this
76
+ * approximates the front hemisphere of a sphere. The sphere radius is half
77
+ * the smaller scale dimension. Fragments outside the sphere are discarded.
78
+ *
79
+ * The depth offset is subtracted from the flat distance, bringing the
80
+ * perceived surface closer to the light. This is an approximation — the
81
+ * true offset direction depends on the light-to-fragment vector, not the
82
+ * camera-to-fragment vector — but for smoke particles the error is
83
+ * negligible.
84
+ */
85
+ const SPHERICAL_DISTANCE_SNIPPET = `
86
+ {
87
+ float sphereOffsetX = (p_uv.x - 0.5) * PARTICLE_SCALE_X;
88
+ float sphereOffsetY = (p_uv.y - 0.5) * PARTICLE_SCALE_Y;
89
+ float sphereRadius = min(PARTICLE_SCALE_X, PARTICLE_SCALE_Y) * 0.5;
90
+ float squaredDistanceFromCenter = sphereOffsetX * sphereOffsetX + sphereOffsetY * sphereOffsetY;
91
+ if (squaredDistanceFromCenter > sphereRadius * sphereRadius) discard;
92
+ float sphereDepthOffset = sqrt(sphereRadius * sphereRadius - squaredDistanceFromCenter);
93
+ dist = max(0.0, dist - sphereDepthOffset);
94
+ }
95
+ `;
96
+ const DISTANCE_CALCULATION_PATTERN = /float\s+dist\s*=\s*length\s*\(\s*vWorldPosition\s*-\s*referencePosition\s*\)\s*;/;
97
+ function buildDistanceMaterial(albedoNodes, distanceAlphaTest, useDistanceAlphaHash, useSphericalDepth) {
98
+ const distanceNodes = albedoNodes.filter((node) => node.affectsDepth);
99
+ const needsFragmentPatch = distanceNodes.length > 0 || useSphericalDepth;
100
+ const material = new MeshDistanceMaterial({
101
+ name: "FXDistanceMaterial",
102
+ side: DoubleSide,
103
+ forceSinglePass: true,
104
+ alphaHash: useDistanceAlphaHash,
105
+ });
106
+ material.onBeforeCompile = (shader) => {
107
+ shader.vertexShader = DISTANCE_VERTEX_SHADER;
108
+ if (!needsFragmentPatch) {
109
+ return;
110
+ }
111
+ const seenCacheKeys = new Set();
112
+ const uniqueHelpers = distanceNodes
113
+ .filter((node) => {
114
+ if (seenCacheKeys.has(node.cacheKey)) {
115
+ return false;
116
+ }
117
+ seenCacheKeys.add(node.cacheKey);
118
+ return true;
119
+ })
120
+ .map((node) => node.helperFunctions)
121
+ .join("\n");
122
+ const fragmentPreamble = [
123
+ PARTICLE_DEFINES,
124
+ "varying mat4 p_builtin;",
125
+ "varying vec2 p_uv;",
126
+ distanceNodes.flatMap((node) => node.uniformDeclarations).join("\n"),
127
+ uniqueHelpers,
128
+ ].join("\n");
129
+ for (const node of distanceNodes) {
130
+ Object.assign(shader.uniforms, node.uniforms);
131
+ }
132
+ let fragmentShader = shader.fragmentShader;
133
+ // Alpha from nodes — MeshDistanceMaterial may or may not include
134
+ // <map_fragment> depending on the Three.js version. If the chunk is
135
+ // absent, insert the alpha discard before the distance calculation.
136
+ if (distanceNodes.length > 0) {
137
+ const combinedAlphaExpression = distanceNodes.map((node) => node.colorExpression).join(" * ");
138
+ const alphaDiscardCode = `float nodeAlpha = (${combinedAlphaExpression}).a;\n` +
139
+ `if (nodeAlpha < ${distanceAlphaTest.toFixed(4)}) discard;`;
140
+ if (fragmentShader.includes("#include <map_fragment>")) {
141
+ fragmentShader = fragmentShader.replace("#include <map_fragment>", alphaDiscardCode);
142
+ }
143
+ else if (DISTANCE_CALCULATION_PATTERN.test(fragmentShader)) {
144
+ // Fallback: inject alpha discard just before the distance calculation.
145
+ fragmentShader = fragmentShader.replace(DISTANCE_CALCULATION_PATTERN, (match) => alphaDiscardCode + "\n" + match);
146
+ }
147
+ else {
148
+ console.warn("FXDistanceMaterial: could not find a suitable injection point for alpha discard. " +
149
+ "This may indicate a Three.js version mismatch.");
150
+ }
151
+ }
152
+ // Spherical distance adjustment — offset the flat-billboard distance by
153
+ // the sphere's front surface depth.
154
+ if (useSphericalDepth) {
155
+ if (!DISTANCE_CALCULATION_PATTERN.test(fragmentShader)) {
156
+ console.warn("FXDistanceMaterial: could not find the distance calculation line. " +
157
+ "Spherical depth will not be applied. " +
158
+ "This may indicate a Three.js version mismatch.");
159
+ }
160
+ else {
161
+ // Inject the spherical adjustment right after the distance calculation.
162
+ fragmentShader = fragmentShader.replace(DISTANCE_CALCULATION_PATTERN, (match) => match + "\n" + SPHERICAL_DISTANCE_SNIPPET);
163
+ }
164
+ }
165
+ shader.fragmentShader = fragmentPreamble + "\n" + fragmentShader;
166
+ };
167
+ material.customProgramCacheKey = () => `fx-distance-${useDistanceAlphaHash ? "alphaHash" : "alphaTest"}-${useSphericalDepth ? "spherical" : "flat"}-${distanceNodes.map((node) => node.cacheKey).join("-") || "none"}`;
168
+ return material;
169
+ }
170
+
171
+ export { buildDistanceMaterial };
@@ -1,19 +1,21 @@
1
- import type { Blending } from "three";
1
+ import type { Blending, MeshDistanceMaterial } from "three";
2
2
  import type { FXColorNode } from "../../nodes/color/FXColorNode";
3
3
  import type { FXTextureNode } from "../../nodes/texture/FXTextureNode";
4
4
  export interface FXMaterialOptions {
5
- albedoNodes?: (FXColorNode | FXTextureNode)[];
6
- blending?: Blending;
7
- useAlphaHashing?: boolean;
8
- alphaTest?: number;
9
- depthAlphaTest?: number;
10
- useDepthAlphaHash?: boolean;
11
- useSphericalDepth?: boolean;
5
+ albedoNodes: (FXColorNode | FXTextureNode)[];
6
+ blending: Blending;
7
+ useAlphaHashing: boolean;
8
+ alphaTest: number;
9
+ depthAlphaTest: number;
10
+ useDepthAlphaHash: boolean;
11
+ useSphericalDepth: boolean;
12
+ premultipliedAlpha: boolean;
12
13
  }
13
14
  export declare abstract class FXMaterial {
14
15
  private readonly depthAlphaTest;
15
16
  private readonly useDepthAlphaHash;
16
17
  private readonly useSphericalDepth;
17
- constructor(options?: FXMaterialOptions);
18
+ constructor(options?: Partial<FXMaterialOptions>);
18
19
  destroy(): void;
20
+ buildDistanceMaterial(): MeshDistanceMaterial;
19
21
  }
@@ -1,12 +1,15 @@
1
1
  import { NormalBlending } from 'three';
2
- import { buildDepthMaterial } from './FXMaterial.Internal.js';
2
+ import { buildDepthMaterial } from './FXDepthMaterial.Internal.js';
3
+ import { buildDistanceMaterial } from './FXDistanceMaterial.Internal.js';
3
4
 
4
5
  class FXMaterial {
5
6
  constructor(options = {}) {
6
7
  this.albedoNodes = options.albedoNodes ?? [];
7
8
  this.blending = options.blending ?? NormalBlending;
8
9
  this.useAlphaHashing = options.useAlphaHashing ?? false;
9
- this.alphaTest = options.alphaTest ?? 1 / 255;
10
+ this.alphaTest = options.alphaTest ?? 0.0075;
11
+ this.premultipliedAlpha =
12
+ (options.useAlphaHashing === undefined && options.premultipliedAlpha) ?? true;
10
13
  this._private_depthAlphaTest = options.depthAlphaTest ?? 0.5;
11
14
  this._private_useDepthAlphaHash = options.useDepthAlphaHash ?? false;
12
15
  this._private_useSphericalDepth = options.useSphericalDepth ?? true;
@@ -20,6 +23,9 @@ class FXMaterial {
20
23
  buildDepthMaterial() {
21
24
  return buildDepthMaterial(this.albedoNodes, this._private_depthAlphaTest, this._private_useDepthAlphaHash, this._private_useSphericalDepth);
22
25
  }
26
+ buildDistanceMaterial() {
27
+ return buildDistanceMaterial(this.albedoNodes, this._private_depthAlphaTest, this._private_useDepthAlphaHash, this._private_useSphericalDepth);
28
+ }
23
29
  }
24
30
 
25
31
  export { FXMaterial };
@@ -3,4 +3,4 @@ import { MeshBasicMaterial } from "three";
3
3
  import type { GLTypeInfo } from "../../instancedParticle/shared";
4
4
  import type { FXColorNode } from "../../nodes/color/FXColorNode";
5
5
  import type { FXTextureNode } from "../../nodes/texture/FXTextureNode";
6
- export declare function buildFXUnlitMaterial(varyings: Record<string, GLTypeInfo>, blending: Blending, useAlphaHashing: boolean, alphaTest: number, albedoNodes: readonly (FXColorNode | FXTextureNode)[]): MeshBasicMaterial;
6
+ export declare function buildFXUnlitMaterial(varyings: Record<string, GLTypeInfo>, blending: Blending, useAlphaHashing: boolean, alphaTest: number, premultipliedAlpha: boolean, albedoNodes: readonly (FXColorNode | FXTextureNode)[]): MeshBasicMaterial;
@@ -1,7 +1,7 @@
1
1
  import { MeshBasicMaterial, DoubleSide } from 'three';
2
2
  import { PARTICLE_DEFINES } from '../../miscellaneous/miscellaneous.js';
3
3
 
4
- function buildFXUnlitMaterial(varyings, blending, useAlphaHashing, alphaTest, albedoNodes) {
4
+ function buildFXUnlitMaterial(varyings, blending, useAlphaHashing, alphaTest, premultipliedAlpha, albedoNodes) {
5
5
  const attributeDeclarations = [];
6
6
  const varyingDeclarations = [];
7
7
  const vertexAssignments = [];
@@ -12,6 +12,7 @@ function buildFXUnlitMaterial(varyings, blending, useAlphaHashing, alphaTest, al
12
12
  vertexAssignments.push(`p_${name} = a_${name};`);
13
13
  }
14
14
  const material = new MeshBasicMaterial({
15
+ name: "FXUnlitMaterial",
15
16
  transparent: !useAlphaHashing,
16
17
  depthWrite: useAlphaHashing,
17
18
  depthTest: true,
@@ -20,6 +21,7 @@ function buildFXUnlitMaterial(varyings, blending, useAlphaHashing, alphaTest, al
20
21
  side: DoubleSide,
21
22
  forceSinglePass: true,
22
23
  alphaTest,
24
+ premultipliedAlpha,
23
25
  });
24
26
  material.onBeforeCompile = (shader) => {
25
27
  const vertexPreamble = [
@@ -2,5 +2,5 @@ import type { FXMaterialOptions } from "../FXMaterial/FXMaterial";
2
2
  import { FXMaterial } from "../FXMaterial/FXMaterial";
3
3
  export type FXUnlitMaterialOptions = FXMaterialOptions;
4
4
  export declare class FXUnlitMaterial extends FXMaterial {
5
- constructor(options?: FXUnlitMaterialOptions);
5
+ constructor(options?: Partial<FXUnlitMaterialOptions>);
6
6
  }
@@ -7,7 +7,7 @@ class FXUnlitMaterial extends FXMaterial {
7
7
  }
8
8
  /** @internal */
9
9
  buildThreeMaterial(varyings) {
10
- return buildFXUnlitMaterial(varyings, this.blending, this.useAlphaHashing, this.alphaTest, this.albedoNodes);
10
+ return buildFXUnlitMaterial(varyings, this.blending, this.useAlphaHashing, this.alphaTest, this.premultipliedAlpha, this.albedoNodes);
11
11
  }
12
12
  }
13
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparcoon",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "particles",
@@ -1,5 +0,0 @@
1
- import { MeshDepthMaterial } from "three";
2
- import type { FXColorNode } from "../../nodes/color/FXColorNode";
3
- import type { FXTextureNode } from "../../nodes/texture/FXTextureNode";
4
- export declare const DEPTH_VERTEX_SHADER = "\n #define PARTICLE_POSITION_X a_builtin[0][0]\n #define PARTICLE_POSITION_Y a_builtin[0][1]\n #define PARTICLE_POSITION_Z a_builtin[0][2]\n\n #define PARTICLE_SCALE_X a_builtin[1][2]\n #define PARTICLE_SCALE_Y a_builtin[1][3]\n\n #define PARTICLE_ROTATION a_builtin[2][1]\n\n attribute mat4 a_builtin;\n varying mat4 p_builtin;\n varying vec2 p_uv;\n varying vec2 vHighPrecisionZW;\n varying float v_viewZ;\n #ifdef USE_ALPHAHASH\n varying vec3 vPosition;\n #endif\n\n void main() {\n p_builtin = a_builtin;\n p_uv = uv;\n #ifdef USE_ALPHAHASH\n vPosition = vec3(PARTICLE_POSITION_X, PARTICLE_POSITION_Y, PARTICLE_POSITION_Z) + vec3(position.xy, 0.0);\n #endif\n\n vec2 transformedPosition = position.xy;\n transformedPosition.x *= PARTICLE_SCALE_X;\n transformedPosition.y *= PARTICLE_SCALE_Y;\n\n float cosR = cos(PARTICLE_ROTATION);\n float sinR = sin(PARTICLE_ROTATION);\n transformedPosition = vec2(\n transformedPosition.x * cosR - transformedPosition.y * sinR,\n transformedPosition.x * sinR + transformedPosition.y * cosR\n );\n\n vec3 center = vec3(PARTICLE_POSITION_X, PARTICLE_POSITION_Y, PARTICLE_POSITION_Z);\n vec4 modelViewCenter = modelViewMatrix * vec4(center, 1.0);\n v_viewZ = modelViewCenter.z;\n modelViewCenter.xy += transformedPosition;\n gl_Position = projectionMatrix * modelViewCenter;\n vHighPrecisionZW = gl_Position.zw;\n }\n";
5
- export declare function buildDepthMaterial(albedoNodes: readonly (FXColorNode | FXTextureNode)[], depthAlphaTest: number, useDepthAlphaHash: boolean, useSphericalDepth: boolean): MeshDepthMaterial;
@@ -1,106 +0,0 @@
1
- import { MeshDepthMaterial, DoubleSide, RGBADepthPacking } from 'three';
2
- import { PARTICLE_DEFINES } from '../../miscellaneous/miscellaneous.js';
3
-
4
- const DEPTH_VERTEX_SHADER = `
5
- #define PARTICLE_POSITION_X a_builtin[0][0]
6
- #define PARTICLE_POSITION_Y a_builtin[0][1]
7
- #define PARTICLE_POSITION_Z a_builtin[0][2]
8
-
9
- #define PARTICLE_SCALE_X a_builtin[1][2]
10
- #define PARTICLE_SCALE_Y a_builtin[1][3]
11
-
12
- #define PARTICLE_ROTATION a_builtin[2][1]
13
-
14
- attribute mat4 a_builtin;
15
- varying mat4 p_builtin;
16
- varying vec2 p_uv;
17
- varying vec2 vHighPrecisionZW;
18
- varying float v_viewZ;
19
- #ifdef USE_ALPHAHASH
20
- varying vec3 vPosition;
21
- #endif
22
-
23
- void main() {
24
- p_builtin = a_builtin;
25
- p_uv = uv;
26
- #ifdef USE_ALPHAHASH
27
- vPosition = vec3(PARTICLE_POSITION_X, PARTICLE_POSITION_Y, PARTICLE_POSITION_Z) + vec3(position.xy, 0.0);
28
- #endif
29
-
30
- vec2 transformedPosition = position.xy;
31
- transformedPosition.x *= PARTICLE_SCALE_X;
32
- transformedPosition.y *= PARTICLE_SCALE_Y;
33
-
34
- float cosR = cos(PARTICLE_ROTATION);
35
- float sinR = sin(PARTICLE_ROTATION);
36
- transformedPosition = vec2(
37
- transformedPosition.x * cosR - transformedPosition.y * sinR,
38
- transformedPosition.x * sinR + transformedPosition.y * cosR
39
- );
40
-
41
- vec3 center = vec3(PARTICLE_POSITION_X, PARTICLE_POSITION_Y, PARTICLE_POSITION_Z);
42
- vec4 modelViewCenter = modelViewMatrix * vec4(center, 1.0);
43
- v_viewZ = modelViewCenter.z;
44
- modelViewCenter.xy += transformedPosition;
45
- gl_Position = projectionMatrix * modelViewCenter;
46
- vHighPrecisionZW = gl_Position.zw;
47
- }
48
- `;
49
- const DEPTH_SPHERICAL_FRAG_COORD_Z = `
50
- float _dx = (p_uv.x - 0.5) * PARTICLE_SCALE_X;
51
- float _dy = (p_uv.y - 0.5) * PARTICLE_SCALE_Y;
52
- float _r = min(PARTICLE_SCALE_X, PARTICLE_SCALE_Y) * 0.5;
53
- float _d2 = _dx * _dx + _dy * _dy;
54
- if (_d2 > _r * _r) discard;
55
- float _sphereZ = v_viewZ + sqrt(_r * _r - _d2);
56
- float _pz = projectionMatrix[2][2] * _sphereZ + projectionMatrix[3][2];
57
- float _pw = projectionMatrix[2][3] * _sphereZ + projectionMatrix[3][3];
58
- float _ndc_z = _pz / _pw;
59
- #ifdef USE_REVERSEDEPTHBUF
60
- float fragCoordZ = _ndc_z;
61
- #else
62
- float fragCoordZ = 0.5 * _ndc_z + 0.5;
63
- #endif
64
- `;
65
- function buildDepthMaterial(albedoNodes, depthAlphaTest, useDepthAlphaHash, useSphericalDepth) {
66
- const depthNodes = albedoNodes.filter((n) => n.affectsDepth);
67
- const needsFragmentMod = depthNodes.length > 0 || useSphericalDepth;
68
- const material = new MeshDepthMaterial({
69
- depthPacking: RGBADepthPacking,
70
- side: DoubleSide,
71
- forceSinglePass: true,
72
- alphaHash: useDepthAlphaHash,
73
- });
74
- material.onBeforeCompile = (shader) => {
75
- shader.vertexShader = DEPTH_VERTEX_SHADER;
76
- if (!needsFragmentMod) {
77
- return;
78
- }
79
- const fragmentPreamble = [
80
- PARTICLE_DEFINES,
81
- "varying mat4 p_builtin;",
82
- "varying vec2 p_uv;",
83
- "varying float v_viewZ;",
84
- useSphericalDepth ? "uniform mat4 projectionMatrix;" : "",
85
- depthNodes.flatMap((n) => n.uniformDeclarations).join("\n"),
86
- depthNodes.map((n) => n.helperFunctions).join("\n"),
87
- ].join("\n");
88
- for (const node of depthNodes) {
89
- Object.assign(shader.uniforms, node.uniforms);
90
- }
91
- let fs = shader.fragmentShader;
92
- if (depthNodes.length > 0) {
93
- const combinedAlpha = depthNodes.map((n) => n.colorExpression).join(" * ");
94
- fs = fs.replace("#include <map_fragment>", `diffuseColor.a = (${combinedAlpha}).a;
95
- if (diffuseColor.a < ${depthAlphaTest.toFixed(4)}) discard;`);
96
- }
97
- if (useSphericalDepth) {
98
- fs = fs.replace(/float fragCoordZ\b[^;]*;/, DEPTH_SPHERICAL_FRAG_COORD_Z);
99
- }
100
- shader.fragmentShader = fragmentPreamble + "\n" + fs;
101
- };
102
- material.customProgramCacheKey = () => `fx-depth-${useDepthAlphaHash ? "ah" : "at"}-${useSphericalDepth ? "sd" : "flat"}-${depthNodes.map((n) => n.cacheKey).join("-") || "none"}`;
103
- return material;
104
- }
105
-
106
- export { DEPTH_VERTEX_SHADER, buildDepthMaterial };