sparcoon 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # sparcoon
2
+
3
+ [![npm](https://img.shields.io/npm/v/sparcoon.svg)](https://www.npmjs.com/package/sparcoon)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
6
+ Instanced-mesh particle system for Three.js. WebGL 1 compatible. **[EDITOR](https://jango-git.github.io/sparcoon/)**
7
+
8
+ ## API
9
+
10
+ - **FXEmitter** extends `Object3D`. Manages emission, lifetime, pooling, rendering. One draw call per emitter. Optional back-to-front sorting.
11
+ - **Spawn modules** set initial particle state: position (box/sphere/point), velocity (cone), rotation, scale, lifetime. Numeric params accept ranges.
12
+ - **Behavior modules** update particles per frame: directional/point gravity, velocity/torque damping, simplex noise, scale-over-life.
13
+ - **Materials**: `FXUnlitMaterial` (MeshBasicMaterial), `FXDiffuseMaterial` (MeshLambertMaterial + optional subsurface scatter). Shadow support.
14
+ - **Nodes** are composable material inputs: color-over-life, static/animated textures, normals (flat/spherical/map).
15
+
16
+ ## Example
17
+
18
+ Smoke volume with animated sprite sheet, subsurface scattering, and shadow receiving:
19
+
20
+ ```typescript
21
+ const RATE = 10;
22
+ const LIFETIME = 6;
23
+
24
+ const smoke = new FXEmitter(
25
+ [
26
+ new FXSpawnBox(areaMin, areaMax),
27
+ new FXSpawnRandomLifetime({ min: LIFETIME * 0.5, max: LIFETIME }),
28
+ new FXSpawnRandomScale({ min: 8, max: 16 }),
29
+ new FXSpawnRandomRotation(),
30
+ ],
31
+ [],
32
+ new FXDiffuseMaterial({
33
+ enableScatter: true,
34
+ scatterPower: 10,
35
+ forwardScatterStrength: 1,
36
+ backScatterStrength: 0.25,
37
+ shadowSensitivity: 0.25,
38
+ premultipliedAlpha: true,
39
+ albedoNodes: [
40
+ new FXAnimatedTextureNode({
41
+ texture: smokeAtlas,
42
+ rows: 8,
43
+ columns: 8,
44
+ interpolate: true,
45
+ }),
46
+ new FXColorOverLifeNode([
47
+ new FXColor(0xffffff, 0),
48
+ new FXColor(0xffffff, 0.4),
49
+ new FXColor(0xffffff, 0.4),
50
+ new FXColor(0xffffff, 0),
51
+ ]),
52
+ ],
53
+ }),
54
+ {
55
+ expectedCapacity: Math.ceil(LIFETIME * RATE) + 1,
56
+ receiveShadow: true,
57
+ sortCamera: camera,
58
+ sortFraction: 0.25,
59
+ },
60
+ );
61
+
62
+ scene.add(smoke);
63
+ smoke.play(RATE);
64
+
65
+ // render loop
66
+ FXEmitter.onWillRender(delta);
67
+ ```
68
+
69
+ ## Install
70
+
71
+ ```
72
+ npm install sparcoon
73
+ ```
74
+
75
+ Peer dep: `three` >=0.157.0 <0.180.0
76
+
77
+ ## License
78
+
79
+ MIT
@@ -17,7 +17,7 @@ export declare class FXEmitter extends Object3D {
17
17
  private readonly spawnSequence;
18
18
  private readonly behaviorSequence;
19
19
  automaticallyDestroyModules: boolean;
20
- sortCamera: Camera | undefined;
20
+ sortCamera?: Camera;
21
21
  sortFraction: number;
22
22
  private readonly mesh;
23
23
  private readonly material;
@@ -40,6 +40,7 @@ function wrapShadowCalls(source, uniformName) {
40
40
  function buildFXDiffuseMaterial(varyings, blending, useAlphaHashing, alphaTest, premultipliedAlpha, albedoNodes, normalNodes, emissionNodes, scatterUniforms, shadowSensitivity) {
41
41
  const useScatter = scatterUniforms !== undefined;
42
42
  const useShadowSensitivity = shadowSensitivity !== undefined;
43
+ const useNormals = normalNodes.length > 0;
43
44
  const attributeDeclarations = [];
44
45
  const varyingDeclarations = [];
45
46
  const vertexAssignments = [];
@@ -67,6 +68,7 @@ function buildFXDiffuseMaterial(varyings, blending, useAlphaHashing, alphaTest,
67
68
  PARTICLE_DEFINES,
68
69
  attributeDeclarations.join("\n"),
69
70
  "varying vec2 p_uv;",
71
+ useNormals ? "varying vec2 p_billboardSinCos;" : "",
70
72
  useScatter ? "varying vec3 v_viewPosition;" : "",
71
73
  varyingDeclarations.join("\n"),
72
74
  ].join("\n");
@@ -96,6 +98,9 @@ function buildFXDiffuseMaterial(varyings, blending, useAlphaHashing, alphaTest,
96
98
  " billboardOffset.x * sinRotation + billboardOffset.y * cosRotation",
97
99
  ");",
98
100
  "",
101
+ // Pass the exact sin/cos that rotated the billboard to the fragment shader.
102
+ useNormals ? "p_billboardSinCos = vec2(sinRotation, cosRotation);" : "",
103
+ "",
99
104
  "vec3 particleCenter = vec3(PARTICLE_POSITION_X, PARTICLE_POSITION_Y, PARTICLE_POSITION_Z);",
100
105
  "vec4 mvPosition = modelViewMatrix * vec4(particleCenter, 1.0);",
101
106
  "mvPosition.xy += billboardOffset;",
@@ -153,6 +158,7 @@ function buildFXDiffuseMaterial(varyings, blending, useAlphaHashing, alphaTest,
153
158
  const fragmentPreamble = [
154
159
  PARTICLE_DEFINES,
155
160
  "varying vec2 p_uv;",
161
+ useNormals ? "varying vec2 p_billboardSinCos;" : "",
156
162
  varyingDeclarations.join("\n"),
157
163
  albedoNodes.flatMap((n) => n.uniformDeclarations).join("\n"),
158
164
  uniqueHelpers(albedoNodes),
@@ -183,7 +189,7 @@ function buildFXDiffuseMaterial(varyings, blending, useAlphaHashing, alphaTest,
183
189
  Object.assign(shader.uniforms, node.uniforms);
184
190
  }
185
191
  let fragmentShader = shader.fragmentShader;
186
- if (normalNodes.length > 0) {
192
+ if (useNormals) {
187
193
  const normalExpressions = normalNodes.map((node) => {
188
194
  if (node instanceof FXTextureNode) {
189
195
  return `normalize(${node.colorExpression}.rgb * 2.0 - 1.0)`;
@@ -197,6 +203,17 @@ function buildFXDiffuseMaterial(varyings, blending, useAlphaHashing, alphaTest,
197
203
  "float faceDirection = gl_FrontFacing ? 1.0 : -1.0;",
198
204
  `vec3 normal = ${normalExpressions[0]};`,
199
205
  ...blendLines,
206
+ "",
207
+ "// Rotate tangent-space normal XY into view space to match billboard rotation.",
208
+ "// p_billboardSinCos carries the exact sin/cos that rotated the quad vertices.",
209
+ "float sinR = p_billboardSinCos.x;",
210
+ "float cosR = p_billboardSinCos.y;",
211
+ "normal = vec3(",
212
+ " normal.x * cosR - normal.y * sinR,",
213
+ " normal.x * sinR + normal.y * cosR,",
214
+ " normal.z",
215
+ ");",
216
+ "",
200
217
  "normal *= faceDirection;",
201
218
  ].join("\n");
202
219
  fragmentShader = fragmentShader.replace("#include <normal_fragment_begin>", normalCode);
@@ -211,8 +228,6 @@ function buildFXDiffuseMaterial(varyings, blending, useAlphaHashing, alphaTest,
211
228
  fragmentShader = fragmentShader.replace("#include <emissivemap_fragment>", `totalEmissiveRadiance = (${combinedExpression}).rgb;`);
212
229
  }
213
230
  if (useScatter) {
214
- // Per-fragment Mie scatter for all light types.
215
- // viewDirection and all light directions/positions are in view space.
216
231
  fragmentShader = fragmentShader.replace("#include <opaque_fragment>", `
217
232
  #ifdef OPAQUE
218
233
  diffuseColor.a = 1.0;
@@ -247,13 +262,6 @@ function buildFXDiffuseMaterial(varyings, blending, useAlphaHashing, alphaTest,
247
262
  }
248
263
  #endif
249
264
 
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
265
  vec3 albedo = diffuseColor.a > 0.001
258
266
  ? diffuseColor.rgb / diffuseColor.a
259
267
  : vec3(0.0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sparcoon",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "",
5
5
  "keywords": [
6
6
  "particles",