From 8106e0657c29e9df8a987ffd24c0f3449e0f7bca Mon Sep 17 00:00:00 2001 From: nbilyk Date: Mon, 1 Dec 2025 10:08:55 -0800 Subject: [PATCH 1/3] refactor: make parseEmitter materials property optional --- packages/three-particles/src/model/ParticleEmitterModel.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/three-particles/src/model/ParticleEmitterModel.ts b/packages/three-particles/src/model/ParticleEmitterModel.ts index c62d0a4..66aa4c2 100644 --- a/packages/three-particles/src/model/ParticleEmitterModel.ts +++ b/packages/three-particles/src/model/ParticleEmitterModel.ts @@ -170,7 +170,7 @@ export function parseEmitter({ geometries, }: { emitterJson: ParticleEmitterModelJson - materials: Record + materials?: Record geometries?: Record }): ParticleEmitterModel { const id = emitterJson.uuid ?? MathUtils.generateUUID() @@ -192,7 +192,7 @@ export function parseEmitter({ .filter(isNonNil) .map((t) => parseTimeline(t)) - const material = toMaterials(emitterJson.material, materials) + const material = toMaterials(emitterJson.material, materials ?? {}) const geometry = toGeometry(emitterJson.geometry, geometries ?? {}) return { From f597ab0dd89d7e432d939b25485f18339b823209 Mon Sep 17 00:00:00 2001 From: nbilyk Date: Wed, 3 Dec 2025 12:48:59 -0800 Subject: [PATCH 2/3] docs(example): limit clock delta --- packages/example/src/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/example/src/index.ts b/packages/example/src/index.ts index 13288c8..3f6f8c2 100644 --- a/packages/example/src/index.ts +++ b/packages/example/src/index.ts @@ -97,8 +97,7 @@ loader .catch(console.error) function render() { - const dT = clock.getDelta() // Must be called to get elapsed time. - + const dT = Math.min(clock.getDelta(), 0.1) controls.update() particleEffect?.update(dT) renderer.render(scene, camera) From dd67cc11932f0eb8f52e19a09a818265754b1b2e Mon Sep 17 00:00:00 2001 From: nbilyk Date: Thu, 4 Dec 2025 09:37:35 -0800 Subject: [PATCH 3/3] feat: add z rotation support for emitter points --- packages/example/resources/fire.json | 294 ++++++++---------- packages/example/src/index.ts | 9 +- .../src/object/ParticleEmitterPoints.ts | 72 ++++- 3 files changed, 209 insertions(+), 166 deletions(-) diff --git a/packages/example/resources/fire.json b/packages/example/resources/fire.json index 21d916b..5d90423 100644 --- a/packages/example/resources/fire.json +++ b/packages/example/resources/fire.json @@ -1,168 +1,134 @@ { - "version": "0.3.0", - "emitters": [ - { - "uuid": "flame", - "name": "flame", - "duration": { - "duration": { - "min": 4, - "max": 6.5 - } - }, - "count": 200, - "emissionRate": { - "property": "emissionRate", - "timeline": [ - 0, - 0, - 0.47999998927116394, - 1, - 1, - 0 - ], - "relative": true, - "low": { - "min": 75 - }, - "high": { - "min": -20, - "max": 15 - } - }, - "particleLifeExpectancy": { - "property": "particleLifeExpectancy", - "timeline": [ - 0.699999988079071, - 0.20000000298023224, - 1, - 1 - ], - "low": { - "min": 1.6, - "max": 2 - }, - "high": { - "min": 0.8, - "max": 1.6 - } - }, - "spawn": { - "type": "ellipsoid", - "w": 1, - "d": 1 - }, - "propertyTimelines": [ - { - "property": "scaleX", - "timeline": [ - 0.4880000054836273, - 1, - 1, - 0.47200000286102295 - ], - "high": { - "min": 1 - } - }, - { - "property": "scaleY", - "timeline": [ - 0.5070000290870667, - 1, - 1, - 0.5 - ], - "high": { - "min": 1 - } - }, + "version": "0.3.0", + "emitters": [ { - "property": "orientationZ", - "timeline": [ - 0, - 1, - 0.09038806706666946, - 0.5047432780265808, - 0.33501696586608887, - 0.12139423191547394, - 0.5705286264419556, - 0 - ], - "low": { - "min": -0.3, - "max": 0.3 - }, - "high": { - "min": -1, - "max": 1 - } - }, - { - "property": "forwardVel", - "timeline": [ - 0, - 1 - ], - "high": { - "min": 1 - } - }, - { - "property": "colorA", - "timeline": [ - 0, - 0, - 0.17100000381469727, - 1, - 0.8019999861717224, - 1, - 1, - 0 - ], - "high": { - "min": 1 - } - }, - { - "property": "color", - "timeline": [ - 0, - 0.9399999976158142, - 0.16500000655651093, - 0.02800000086426735, - 1, - 0.5099999904632568, - 0.09200000017881393, - 0 - ], - "high": { - "min": 1 - } + "uuid": "flame", + "name": "flame", + "duration": { + "duration": { + "min": 4, + "max": 6.5 + } + }, + "count": 200, + "emissionRate": { + "property": "emissionRate", + "timeline": [0, 0, 0.47999998927116394, 1, 1, 0], + "relative": true, + "low": { + "min": 75 + }, + "high": { + "min": -20, + "max": 15 + } + }, + "particleLifeExpectancy": { + "property": "particleLifeExpectancy", + "timeline": [0.699999988079071, 0.20000000298023224, 1, 1], + "low": { + "min": 1.6, + "max": 2 + }, + "high": { + "min": 0.8, + "max": 1.6 + } + }, + "spawn": { + "type": "ellipsoid", + "w": 1, + "d": 1 + }, + "propertyTimelines": [ + { + "property": "scaleX", + "timeline": [0.4880000054836273, 1, 1, 0.47200000286102295], + "high": { + "min": 1 + } + }, + { + "property": "scaleY", + "timeline": [0.5070000290870667, 1, 1, 0.5], + "high": { + "min": 1 + } + }, + { + "property": "orientationZ", + "timeline": [ + 0, 1, 0.09038806706666946, 0.5047432780265808, + 0.33501696586608887, 0.12139423191547394, + 0.5705286264419556, 0 + ], + "low": { + "min": -0.3, + "max": 0.3 + }, + "high": { + "min": -1, + "max": 1 + } + }, + { + "property": "forwardVel", + "timeline": [0, 1], + "high": { + "min": 1 + } + }, + { + "property": "colorA", + "timeline": [ + 0, 0, 0.17100000381469727, 1, 0.8019999861717224, 1, 1, + 0 + ], + "high": { + "min": 1 + } + }, + { + "property": "color", + "timeline": [ + 0, 0.9399999976158142, 0.16500000655651093, + 0.02800000086426735, 1, 0.5099999904632568, + 0.09200000017881393, 0 + ], + "high": { + "min": 1 + } + }, + { + "property": "rotationZ", + "timeline": [0, 0, 1, 1], + "high": { + "min": 6.28 + } + } + ], + "material": "points" + } + ], + "materials": { + "points": { + "metadata": { + "version": 4.7, + "type": "Material", + "generator": "Material.toJSON" + }, + "uuid": "623db468-8d1b-4ce3-a8e0-cebf27588d4f", + "type": "PointsMaterial", + "color": 16777215, + "map": "diamond", + "size": 0.3, + "sizeAttenuation": true, + "blending": 2, + "vertexColors": true, + "transparent": true, + "blendColor": 0, + "depthWrite": false, + "alphaTest": 0.01 } - ], - "material": "points" - } - ], - "materials": { - "points": { - "metadata": { - "version": 4.7, - "type": "Material", - "generator": "Material.toJSON" - }, - "uuid": "623db468-8d1b-4ce3-a8e0-cebf27588d4f", - "type": "PointsMaterial", - "color": 16777215, - "map": "radial", - "size": 0.3, - "sizeAttenuation": true, - "blending": 2, - "vertexColors": true, - "transparent": true, - "blendColor": 0, - "depthWrite": false, - "alphaTest": 0.01 } - } -} \ No newline at end of file +} diff --git a/packages/example/src/index.ts b/packages/example/src/index.ts index 3f6f8c2..9838c03 100644 --- a/packages/example/src/index.ts +++ b/packages/example/src/index.ts @@ -11,6 +11,7 @@ import { PerspectiveCamera, PlaneGeometry, Scene, + TextureLoader, Vector3, WebGLRenderer, } from 'three' @@ -88,8 +89,14 @@ function onResize() { let particleEffect: ParticleEffect | null = null const loader = new ParticleEffectLoader() +// Bind the "diamond" texture key used in fire.json to the local diamond.png file. +const textureLoader = new TextureLoader() +loader.setTextures({ + diamond: textureLoader.load('./diamond.png'), +}) + loader - .loadAsync('./mesh.json') + .loadAsync('./fire.json') .then((model) => { particleEffect = new ParticleEffect(model) scene.add(particleEffect) diff --git a/packages/three-particles/src/object/ParticleEmitterPoints.ts b/packages/three-particles/src/object/ParticleEmitterPoints.ts index 64acbda..57fc615 100644 --- a/packages/three-particles/src/object/ParticleEmitterPoints.ts +++ b/packages/three-particles/src/object/ParticleEmitterPoints.ts @@ -1,4 +1,4 @@ -import { Float32BufferAttribute, Points } from 'three' +import { Float32BufferAttribute, Points, PointsMaterial } from 'three' import { ParticleEmitterState } from '../state' import { ParticleEmitterObject } from './ParticleEmitterObject' import { ParticleEmitterModel } from '../model' @@ -31,6 +31,16 @@ export class ParticleEmitterPoints new Float32BufferAttribute(new Float32Array(n * 4), 4), ) + // Per-particle rotation (around Z axis, in radians). + // This is used by a custom shader patch on PointsMaterial + // to rotate the point sprite. + this.geometry.setAttribute( + 'rotation', + new Float32BufferAttribute(new Float32Array(n), 1), + ) + + this.configureMaterialForRotation() + // // Set a default bounding sphere (optional): // this.geometry.boundingSphere = new Sphere(new Vector3(0, 0, 0), 10) } @@ -42,6 +52,8 @@ export class ParticleEmitterPoints if (!this.state.model.enabled) return const posArr = this.geometry.attributes.position.array as Float32Array const colorArr = this.geometry.attributes.color.array as Float32Array + const rotationArr = this.geometry.attributes.rotation + .array as Float32Array let i = 0 for (const particle of this.state.particles) { @@ -58,12 +70,70 @@ export class ParticleEmitterPoints colorArr[k + 1] = tint.g colorArr[k + 2] = tint.b colorArr[k + 3] = tint.a + + // Use the particle's Z Euler rotation to rotate the point sprite. + // rotationFinal includes orientation when enabled on the emitter. + rotationArr[i] = particle.rotationFinal.z i++ } this.geometry.setDrawRange(0, this.state.activeCount) this.geometry.attributes.position.needsUpdate = true this.geometry.attributes.color.needsUpdate = true + this.geometry.attributes.rotation.needsUpdate = true + } + + /** + * Configure the material so that point sprites rotate using the per-particle + * `rotation` attribute (Z Euler). + */ + private configureMaterialForRotation(): void { + const materials = Array.isArray(this.material) + ? this.material + : [this.material] + + for (const mat of materials) { + if (!(mat instanceof PointsMaterial)) continue + + mat.onBeforeCompile = (shader) => { + // Vertex shader: declare attribute + varying and pass rotation through. + shader.vertexShader = + 'attribute float rotation;\n' + + 'varying float vRotation;\n' + + shader.vertexShader.replace( + '#include ', + '#include \n vRotation = rotation;\n', + ) + + // Fragment shader: declare varying and rotate gl_PointCoord + // around the center before sampling textures. + // + // NOTE: three.js' default PointsMaterial fragment shader uses + // `#include ` instead of an explicit + // `vec2 uv = gl_PointCoord;` line. Because `onBeforeCompile` + // receives the shader source *before* includes are expanded, + // we must replace that include block rather than looking for + // the generated lines. + shader.fragmentShader = + 'varying float vRotation;\n' + + shader.fragmentShader.replace( + '#include ', + [ + '#ifdef USE_MAP', + ' vec2 uv = gl_PointCoord;', + ' uv -= 0.5;', + ' float s = sin(vRotation);', + ' float c = cos(vRotation);', + ' mat2 rot = mat2(c, -s, s, c);', + ' uv = rot * uv;', + ' uv += 0.5;', + ' vec4 mapTexel = texture2D(map, uv);', + ' diffuseColor *= mapTexel;', + '#endif', + ].join('\n'), + ) + } + } } rewind(): void {