From ee7691b0a1a6f538199912f190a439597c19d330 Mon Sep 17 00:00:00 2001 From: nbilyk Date: Thu, 2 Oct 2025 19:30:42 -0700 Subject: [PATCH 1/9] refactor: change fire units and camera to more idiomatic defaults --- packages/example/resources/fire.json | 10 +++++----- packages/example/src/index.ts | 15 +++++---------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/packages/example/resources/fire.json b/packages/example/resources/fire.json index 53f8dd3..2dd039b 100644 --- a/packages/example/resources/fire.json +++ b/packages/example/resources/fire.json @@ -3,7 +3,7 @@ "materials": { "points": { "type": "PointsMaterial", - "size": 6, + "size": 0.3, "sizeAttenuation": true, "transparent": true, "map": "radial", @@ -21,9 +21,9 @@ "loops": true, "spawn": { "type": "ellipsoid", - "w": 2, + "w": 0.1, "h": 0, - "d": 2 + "d": 0.1 }, "duration": { "duration": { @@ -127,8 +127,8 @@ "ease": "linear" }, "high": { - "min": 12, - "max": 12, + "min": 1, + "max": 1, "ease": "linear" }, "property": "forwardVelocity", diff --git a/packages/example/src/index.ts b/packages/example/src/index.ts index 45ca4fc..3db202a 100644 --- a/packages/example/src/index.ts +++ b/packages/example/src/index.ts @@ -4,23 +4,18 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' console.log('Hello!') -const camera = new THREE.PerspectiveCamera( - 45, - window.innerWidth / window.innerHeight, - 0.1, - 1000, -) -camera.position.set(0, 10, 20) +const camera = new THREE.PerspectiveCamera() +camera.position.set(0, 1, 2) -camera.lookAt(new THREE.Vector3(0, 2, 0)) +camera.lookAt(new THREE.Vector3(0, 0.2, 0)) const scene = new THREE.Scene() scene.background = new THREE.Color(0x111111) -scene.fog = new THREE.Fog(0x111111, 20, 100) +scene.fog = new THREE.Fog(0x111111, 1, 10) const clock = new THREE.Clock() -const grid = new THREE.GridHelper(200, 40, 0x000000, 0xffffff) +const grid = new THREE.GridHelper(20, 20, 0x000000, 0xffffff) grid.material.opacity = 0.2 grid.material.transparent = true scene.add(grid) From fc79e63960575e658c4f4528270239aa97fdfe79 Mon Sep 17 00:00:00 2001 From: nbilyk Date: Thu, 2 Oct 2025 20:00:52 -0700 Subject: [PATCH 2/9] refactor: remove star imports from THREE --- packages/example/src/index.ts | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/example/src/index.ts b/packages/example/src/index.ts index 3db202a..4853e17 100644 --- a/packages/example/src/index.ts +++ b/packages/example/src/index.ts @@ -1,28 +1,37 @@ -import * as THREE from 'three' +import { + Clock, + Color, + Fog, + GridHelper, + PerspectiveCamera, + Scene, + Vector3, + WebGLRenderer, +} from 'three' import { ParticleEffect, ParticleEffectLoader } from 'three-particles' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' console.log('Hello!') -const camera = new THREE.PerspectiveCamera() +const camera = new PerspectiveCamera() camera.position.set(0, 1, 2) -camera.lookAt(new THREE.Vector3(0, 0.2, 0)) +camera.lookAt(new Vector3(0, 0.2, 0)) -const scene = new THREE.Scene() -scene.background = new THREE.Color(0x111111) -scene.fog = new THREE.Fog(0x111111, 1, 10) +const scene = new Scene() +scene.background = new Color(0x111111) +scene.fog = new Fog(0x111111, 1, 10) -const clock = new THREE.Clock() +const clock = new Clock() -const grid = new THREE.GridHelper(20, 20, 0x000000, 0xffffff) +const grid = new GridHelper(20, 20, 0x000000, 0xffffff) grid.material.opacity = 0.2 grid.material.transparent = true scene.add(grid) const canvas = document.querySelector('#mainCanvas') as HTMLCanvasElement -const renderer = new THREE.WebGLRenderer({ +const renderer = new WebGLRenderer({ canvas, antialias: true, }) From 2c1f5a13be64d3b5ddd69085e852a78faa746e00 Mon Sep 17 00:00:00 2001 From: nbilyk Date: Fri, 3 Oct 2025 21:50:45 -0700 Subject: [PATCH 3/9] feat: mesh material example --- packages/example/resources/diamond.png | Bin 0 -> 587 bytes .../resources/meshMaterialExample.json | 158 ++++++++++++++++++ .../src/object/ParticleEffect.ts | 11 +- .../src/object/ParticleEmitterPoints.ts | 4 +- .../src/object/ParticleEmitterQuads.ts | 76 +++++++++ packages/three-particles/src/object/index.ts | 1 + 6 files changed, 245 insertions(+), 5 deletions(-) create mode 100644 packages/example/resources/diamond.png create mode 100644 packages/example/resources/meshMaterialExample.json create mode 100644 packages/three-particles/src/object/ParticleEmitterQuads.ts diff --git a/packages/example/resources/diamond.png b/packages/example/resources/diamond.png new file mode 100644 index 0000000000000000000000000000000000000000..226b4f28912d3215aff4fe2ab0ee07cdca7ec266 GIT binary patch literal 587 zcmV-R0<`^!P)|zXw zUHXWLxx457{QH9p!sY9SBLEKbegCt|c}6`brSuw&#@GK6=bXq(peP!IH#@Y0haf%ve(!a#1YMe>`5_eJKboxTv zRgKf>3-Pz8aaH<9{HmSCLVoIsy z>2%s;Sq8R;D&OsP-{Rr?? zaR7igj+f{2d7~{6)s=C)`z*^6N?50^j03pob!u&#syC^%aR8d$q&CKFdYjr92Qcex zYHM7lr>L!QfNDKOO^TQGR5d9MP}Eb^^ literal 0 HcmV?d00001 diff --git a/packages/example/resources/meshMaterialExample.json b/packages/example/resources/meshMaterialExample.json new file mode 100644 index 0000000..a8d3553 --- /dev/null +++ b/packages/example/resources/meshMaterialExample.json @@ -0,0 +1,158 @@ +{ + "version": "0.3.0", + "materials": { + "points": { + "type": "MeshBasicMaterial", + "transparent": true, + "map": "radial", + "blending": 2, + + "depthWrite": false, + "vertexColors": false + } + }, + "emitters": [ + { + "uuid": "flame", + "enabled": true, + "material": "points", + "loops": true, + "spawn": { + "type": "ellipsoid", + "w": 0.1, + "h": 0, + "d": 0.1 + }, + "duration": { + "duration": { + "min": 4, + "max": 6.5, + "ease": "linear" + }, + "delayBefore": { + "min": 0, + "max": 0, + "ease": "linear" + }, + "delayAfter": { + "min": 0, + "max": 0, + "ease": "linear" + } + }, + "count": 200, + "emissionRate": { + "low": { + "min": 75, + "max": 75, + "ease": "linear" + }, + "high": { + "min": -20, + "max": 15, + "ease": "linear" + }, + "property": "emissionRate", + "relative": true, + "timeline": [0, 0, 0.48, 1, 1, 0] + }, + "particleLifeExpectancy": { + "low": { + "min": 1.6, + "max": 2, + "ease": "linear" + }, + "high": { + "min": 0.8, + "max": 1.6, + "ease": "linear" + }, + "property": "particleLifeExpectancy", + "relative": false, + "timeline": [0.7, 0.2, 1, 1] + }, + "orientToForwardDirection": false, + "propertyTimelines": [ + { + "low": { + "min": 0, + "max": 0, + "ease": "linear" + }, + "high": { + "min": 1, + "max": 1, + "ease": "linear" + }, + "property": "scaleX", + "relative": false, + "timeline": [0.488, 1, 1, 0.472] + }, + { + "low": { + "min": 0, + "max": 0, + "ease": "linear" + }, + "high": { + "min": 1, + "max": 1, + "ease": "linear" + }, + "property": "scaleY", + "relative": false, + "timeline": [0.488, 1, 1, 0.472] + }, + { + "low": { + "min": 2.356, + "max": 0.785, + "ease": "linear" + }, + "high": { + "min": 1.571, + "max": 1.571, + "ease": "linear" + }, + "property": "forwardDirectionZ", + "relative": false, + "timeline": [0, 0, 0.409, 1] + }, + { + "low": { + "min": 0, + "max": 0, + "ease": "linear" + }, + "high": { + "min": 1, + "max": 1, + "ease": "linear" + }, + "property": "forwardVelocity", + "relative": false, + "timeline": [0, 1] + }, + { + "low": { + "min": 0, + "max": 0, + "ease": "linear" + }, + "high": { + "min": 1, + "max": 1, + "ease": "linear" + }, + "property": "colorA", + "relative": false, + "timeline": [0, 0, 0.171, 1, 0.802, 1, 1, 0] + }, + { + "property": "color", + "timeline": [0, 0.94, 0.165, 0.028, 0.96, 0.51, 0.092, 0] + } + ] + } + ] +} diff --git a/packages/three-particles/src/object/ParticleEffect.ts b/packages/three-particles/src/object/ParticleEffect.ts index 0893cbc..17c2ec6 100644 --- a/packages/three-particles/src/object/ParticleEffect.ts +++ b/packages/three-particles/src/object/ParticleEffect.ts @@ -1,5 +1,6 @@ import { ParticleEmitterPoints } from './ParticleEmitterPoints' -import { Group } from 'three' +import { ParticleEmitterQuads } from './ParticleEmitterQuads' +import { Group, PointsMaterial } from 'three' import { isParticleEmitterObject, ParticleEmitterObject, @@ -43,7 +44,13 @@ export class ParticleEffect extends Group { this.clear() for (const emitter of this.model.emitters) { - const instance = new ParticleEmitterPoints(emitter) + const mat = Array.isArray(emitter.material) + ? emitter.material[0] + : emitter.material + const usePoints = mat instanceof PointsMaterial + const instance = usePoints + ? new ParticleEmitterPoints(emitter) + : new ParticleEmitterQuads(emitter) this.add(instance) } } diff --git a/packages/three-particles/src/object/ParticleEmitterPoints.ts b/packages/three-particles/src/object/ParticleEmitterPoints.ts index 3569a9a..a1c2a5b 100644 --- a/packages/three-particles/src/object/ParticleEmitterPoints.ts +++ b/packages/three-particles/src/object/ParticleEmitterPoints.ts @@ -16,7 +16,7 @@ export class ParticleEmitterPoints readonly state: ParticleEmitterState constructor(model: ParticleEmitterModel) { - super() + super(undefined, model.material ?? undefined) this.state = new ParticleEmitterState(model) const n = model.count @@ -33,8 +33,6 @@ export class ParticleEmitterPoints // // Set a default bounding sphere (optional): // this.geometry.boundingSphere = new Sphere(new Vector3(0, 0, 0), 10) - - if (model.material) this.material = model.material } /** diff --git a/packages/three-particles/src/object/ParticleEmitterQuads.ts b/packages/three-particles/src/object/ParticleEmitterQuads.ts new file mode 100644 index 0000000..2bad1b1 --- /dev/null +++ b/packages/three-particles/src/object/ParticleEmitterQuads.ts @@ -0,0 +1,76 @@ +import { + Color, + InstancedMesh, + Object3D, + PlaneGeometry, + Quaternion, + Vector3, +} from 'three' +import { ParticleEmitterState } from '../state' +import { ParticleEmitterObject } from './ParticleEmitterObject' +import { ParticleEmitterModel } from '../model' + +const VEC_Z = new Vector3(0, 0, 1) + +/** + * ParticleEmitterQuads renders particles as camera-facing quads (via instancing). + * Supports non-uniform scale (scaleX/scaleY) and rotation around Z. + */ +export class ParticleEmitterQuads + extends InstancedMesh + implements ParticleEmitterObject +{ + readonly isParticleEmitterObject = true as const + readonly state: ParticleEmitterState + + private readonly _capacity: number + private readonly _dummy = new Object3D() + private readonly _quat = new Quaternion() + + constructor(model: ParticleEmitterModel) { + const count = model.count + // Use a simple unit plane. User-provided material is applied below. + super(new PlaneGeometry(1, 1), model.material ?? undefined, count) + + this._capacity = count + this.state = new ParticleEmitterState(model) + + // Optionally, set frustumCulled false since particles may be spread. + this.frustumCulled = false + } + + updateGeometry(): void { + if (!this.state.model.enabled) return + + const color = new Color() + let index = 0 + + for (const p of this.state.particles) { + if (!p.active) continue + + // Position + this._dummy.position.copy(p.position) + + // Rotation around Z (screen-space style). We'll rotate quad around local Z. + this._quat.setFromAxisAngle(VEC_Z, p.rotationFinal.z) + this._dummy.quaternion.copy(this._quat) + + this._dummy.scale.copy(p.scale) + + this._dummy.updateMatrix() + this.setMatrixAt(index, this._dummy.matrix) + + // Instance color (RGB). Alpha is not supported per-instance on standard materials. + color.setRGB(p.tint.r, p.tint.g, p.tint.b) + this.setColorAt(index, color) + + index++ + if (index >= this._capacity) break + } + + // Update how many instances to draw + this.count = index + this.instanceMatrix.needsUpdate = true + if (this.instanceColor) this.instanceColor.needsUpdate = true + } +} diff --git a/packages/three-particles/src/object/index.ts b/packages/three-particles/src/object/index.ts index 5b28bce..785f92b 100644 --- a/packages/three-particles/src/object/index.ts +++ b/packages/three-particles/src/object/index.ts @@ -1,3 +1,4 @@ export * from './ParticleEffect' export * from './ParticleEmitterObject' export * from './ParticleEmitterPoints' +export * from './ParticleEmitterQuads' From 508c6c7d5080f147f4b63f8307cc91e92fedd9af Mon Sep 17 00:00:00 2001 From: nbilyk Date: Mon, 27 Oct 2025 20:13:10 -0700 Subject: [PATCH 4/9] feat: support bundled textures --- .../src/ParticleEffectLoader.ts | 59 ++++++++--- .../src/model/ParticleEffectModel.ts | 6 +- .../three-particles/src/parseTextureJson.ts | 53 ++++++++++ .../test/parseTextureJson.test.ts | 99 +++++++++++++++++++ 4 files changed, 205 insertions(+), 12 deletions(-) create mode 100644 packages/three-particles/src/parseTextureJson.ts create mode 100644 packages/three-particles/test/parseTextureJson.test.ts diff --git a/packages/three-particles/src/ParticleEffectLoader.ts b/packages/three-particles/src/ParticleEffectLoader.ts index d4a5194..166e647 100644 --- a/packages/three-particles/src/ParticleEffectLoader.ts +++ b/packages/three-particles/src/ParticleEffectLoader.ts @@ -1,45 +1,61 @@ -import { FileLoader, Loader, Material, MaterialLoader } from 'three' +import { + FileLoader, + Loader, + Material, + MaterialLoader, + Texture, + TextureLoader, +} from 'three' +import { parseTextureJson } from './parseTextureJson' import { ParticleEffectModelJson, parseParticleEffect, ParticleEffectModel, } from './model' import { LoadingManager } from 'three/src/loaders/LoadingManager' -import { getDefaultRadial } from './materialDefaults' import { decodeText } from './util' import { cloneDeep } from 'lodash' +import { getDefaultRadial } from './materialDefaults' /** * Loads a JSON file describing a particle effect. */ export class ParticleEffectLoader extends Loader { public readonly materialLoader: MaterialLoader + public readonly textureLoader: TextureLoader public materials: Record = {} + public textures: Record = {} constructor( manager?: LoadingManager, deps?: { readonly materialLoader?: MaterialLoader + readonly textureLoader?: TextureLoader }, ) { super(manager) this.materialLoader = deps?.materialLoader ?? new MaterialLoader(manager) + this.textureLoader = deps?.textureLoader ?? new TextureLoader(manager) - // TODO temp - this.materialLoader.setTextures({ - radial: getDefaultRadial(), - }) + // Provide a sensible default texture map so basic effects work out of the box. + // Users can override via setTextures(), and JSON-bundled textures will be merged. + this.setTextures({ radial: getDefaultRadial() }) } setMaterials(materials: Record) { this.materials = materials } + setTextures(textures: Record) { + this.textures = textures + } + setPath(path: string): this { super.setPath(path) this.materialLoader.setPath(path) + this.textureLoader.setPath(path) return this } @@ -87,10 +103,31 @@ export class ParticleEffectLoader extends Loader { json: ParticleEffectModelJson, ): Promise { json = cloneDeep(json) + + // Load bundled textures first so materials can reference them by key. + const bundledTextures: Record = {} + if (json.textures) { + const tLoader = this.textureLoader + for (const [key, tex] of Object.entries(json.textures)) { + if (typeof tex === 'string') { + // Texture was set to a URL. + bundledTextures[key] = await tLoader.loadAsync(tex) + } else { + // Parse a full TextureJSON blob by loading its image and applying properties. + bundledTextures[key] = parseTextureJson(tex, tLoader) + } + } + } + + // Make textures available to MaterialLoader for resolving map/alphaMap/etc. + const externalTextures = this.textures + const allTextures = { ...externalTextures, ...bundledTextures } + const mLoader = this.materialLoader + mLoader.setTextures(allTextures) + + // 2) Load bundled materials const bundledMaterials: Record = {} if (json.materials) { - // Load materials from the JSON. - const mLoader = this.materialLoader for (const [key, material] of Object.entries(json.materials)) { bundledMaterials[key] = typeof material === 'string' @@ -98,13 +135,13 @@ export class ParticleEffectLoader extends Loader { : mLoader.parse(material) } } - // TODO: bundled textures + return parseParticleEffect( json, bundledMaterials, this.materials, - {}, - this.materialLoader.textures, + bundledTextures, + externalTextures, ) } } diff --git a/packages/three-particles/src/model/ParticleEffectModel.ts b/packages/three-particles/src/model/ParticleEffectModel.ts index 44cfc9a..6ce2b04 100644 --- a/packages/three-particles/src/model/ParticleEffectModel.ts +++ b/packages/three-particles/src/model/ParticleEffectModel.ts @@ -58,7 +58,11 @@ export type ParticleEffectModelJson = Omit< > & { emitters?: ParticleEmitterModelJson[] materials?: Record - textures?: Record + + /** + * Allow either TextureJSON blobs or string URLs/keys for textures + */ + textures?: Record } /** diff --git a/packages/three-particles/src/parseTextureJson.ts b/packages/three-particles/src/parseTextureJson.ts new file mode 100644 index 0000000..8ba1115 --- /dev/null +++ b/packages/three-particles/src/parseTextureJson.ts @@ -0,0 +1,53 @@ +import { Texture, TextureLoader, TextureJSON } from 'three' +import { Wrapping } from 'three/src/constants' +import { cloneDeep } from 'lodash' + +/** + * Parse a THREE.TextureJSON object by loading its image and applying fields. + * Note: Only commonly used fields are applied here. For full parity with ObjectLoader, + * consider integrating THREE.ObjectLoader in the future. + */ +export function parseTextureJson( + json: Partial, + textureLoader: TextureLoader, +): Texture { + const t = json.image ? textureLoader.load(json.image) : new Texture() + + // identity defaults + if (json.name) t.name = json.name + if (json.uuid) t.uuid = json.uuid + + // transforms + if (json.repeat) t.repeat.set(json.repeat[0], json.repeat[1]) + if (json.offset) t.offset.set(json.offset[0], json.offset[1]) + if (json.center) t.center.set(json.center[0], json.center[1]) + if (typeof json.rotation === 'number') t.rotation = json.rotation + + // addressing / wrapping + if (json.wrap) { + t.wrapS = json.wrap[0] as Wrapping + t.wrapT = json.wrap[1] as Wrapping + } + if (json.mapping !== undefined) t.mapping = json.mapping + + // sampling / filtering + if (json.magFilter !== undefined) t.magFilter = json.magFilter + if (json.minFilter !== undefined) t.minFilter = json.minFilter + if (typeof json.anisotropy === 'number') t.anisotropy = json.anisotropy + + // format / type / color space + if (json.format !== undefined) t.format = json.format + if (json.type !== undefined) t.type = json.type + if (json.colorSpace !== undefined) t.colorSpace = json.colorSpace + + // mipmaps and flags + if (typeof json.flipY === 'boolean') t.flipY = json.flipY + if (typeof json.generateMipmaps === 'boolean') + t.generateMipmaps = json.generateMipmaps + + // user data + if (json.userData) t.userData = cloneDeep(json.userData) + + t.needsUpdate = true + return t +} diff --git a/packages/three-particles/test/parseTextureJson.test.ts b/packages/three-particles/test/parseTextureJson.test.ts new file mode 100644 index 0000000..9bc4921 --- /dev/null +++ b/packages/three-particles/test/parseTextureJson.test.ts @@ -0,0 +1,99 @@ +import { + FloatType, + LinearMipmapLinearFilter, + MirroredRepeatWrapping, + NearestFilter, + RepeatWrapping, + RGBAFormat, + SRGBColorSpace, + Texture, + TextureJSON, + TextureLoader, + UVMapping, +} from 'three' +import { parseTextureJson } from '../src/parseTextureJson' + +class FakeTextureLoader extends TextureLoader { + public lastUrl: string | null = null + load(url: string): Texture { + this.lastUrl = url + return new Texture() + } +} + +describe('parseTextureJson', () => { + test('applies full TextureJSON fields and loads image via loader', () => { + const loader = new FakeTextureLoader() + + const json = { + name: 'myTex', + uuid: '1234-5678', + image: 'data:image/png;base64,XXXX', + mapping: UVMapping as any, + repeat: [2, 3] as [number, number], + offset: [0.25, 0.5] as [number, number], + center: [0.1, 0.2] as [number, number], + rotation: Math.PI / 4, + wrap: [RepeatWrapping as any, MirroredRepeatWrapping as any] as any, + format: RGBAFormat as any, + type: FloatType as any, + colorSpace: SRGBColorSpace as any, + magFilter: NearestFilter as any, + minFilter: LinearMipmapLinearFilter as any, + anisotropy: 8, + flipY: true, + generateMipmaps: false, + userData: { nested: { value: 42 } }, + } satisfies Partial + + const tex = parseTextureJson(json as any, loader) + + expect(loader.lastUrl).toBe(json.image) + expect(tex.name).toBe('myTex') + expect(tex.uuid).toBe('1234-5678') + + expect(tex.repeat.x).toBe(2) + expect(tex.repeat.y).toBe(3) + expect(tex.offset.x).toBe(0.25) + expect(tex.offset.y).toBe(0.5) + expect(tex.center.x).toBe(0.1) + expect(tex.center.y).toBe(0.2) + expect(tex.rotation).toBeCloseTo(Math.PI / 4) + + expect(tex.wrapS).toBe(RepeatWrapping) + expect(tex.wrapT).toBe(MirroredRepeatWrapping) + expect(tex.mapping).toBe(UVMapping) + + expect(tex.magFilter).toBe(NearestFilter) + expect(tex.minFilter).toBe(LinearMipmapLinearFilter) + expect(tex.anisotropy).toBe(8) + + expect(tex.format).toBe(RGBAFormat) + expect(tex.type).toBe(FloatType) + expect(tex.colorSpace).toBe(SRGBColorSpace) + + expect(tex.flipY).toBe(true) + expect(tex.generateMipmaps).toBe(false) + + // userData should be deep-cloned + expect(tex.userData).toEqual({ nested: { value: 42 } }) + // mutate original json and verify texture.userData not affected + json.userData.nested.value = 100 + expect(tex.userData).toEqual({ nested: { value: 42 } }) + }) + + test('creates Texture without image and applies partial fields', () => { + const loader = new FakeTextureLoader() + const json = { + name: 'noImage', + flipY: false, + } + const tex = parseTextureJson(json as any, loader) + + // no image should not trigger loader + expect(loader.lastUrl).toBeNull() + expect(tex).toBeInstanceOf(Texture) + expect(tex.name).toBe('noImage') + expect(tex.flipY).toBe(false) + }) +}) From 7d5318ffff8689a55cdf0a2f9b02270433403d68 Mon Sep 17 00:00:00 2001 From: nbilyk Date: Mon, 27 Oct 2025 20:22:33 -0700 Subject: [PATCH 5/9] refactor: rename members of ParticleEmitterQuads.ts --- .../src/object/ParticleEmitterQuads.ts | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/three-particles/src/object/ParticleEmitterQuads.ts b/packages/three-particles/src/object/ParticleEmitterQuads.ts index 2bad1b1..7ee9922 100644 --- a/packages/three-particles/src/object/ParticleEmitterQuads.ts +++ b/packages/three-particles/src/object/ParticleEmitterQuads.ts @@ -23,16 +23,17 @@ export class ParticleEmitterQuads readonly isParticleEmitterObject = true as const readonly state: ParticleEmitterState - private readonly _capacity: number - private readonly _dummy = new Object3D() - private readonly _quat = new Quaternion() + private readonly color = new Color() + private readonly capacity: number + private readonly obj = new Object3D() + private readonly quat = new Quaternion() constructor(model: ParticleEmitterModel) { const count = model.count // Use a simple unit plane. User-provided material is applied below. super(new PlaneGeometry(1, 1), model.material ?? undefined, count) - this._capacity = count + this.capacity = count this.state = new ParticleEmitterState(model) // Optionally, set frustumCulled false since particles may be spread. @@ -42,30 +43,29 @@ export class ParticleEmitterQuads updateGeometry(): void { if (!this.state.model.enabled) return - const color = new Color() let index = 0 for (const p of this.state.particles) { if (!p.active) continue // Position - this._dummy.position.copy(p.position) + this.obj.position.copy(p.position) // Rotation around Z (screen-space style). We'll rotate quad around local Z. - this._quat.setFromAxisAngle(VEC_Z, p.rotationFinal.z) - this._dummy.quaternion.copy(this._quat) + this.quat.setFromAxisAngle(VEC_Z, p.rotationFinal.z) + this.obj.quaternion.copy(this.quat) - this._dummy.scale.copy(p.scale) + this.obj.scale.copy(p.scale) - this._dummy.updateMatrix() - this.setMatrixAt(index, this._dummy.matrix) + this.obj.updateMatrix() + this.setMatrixAt(index, this.obj.matrix) // Instance color (RGB). Alpha is not supported per-instance on standard materials. - color.setRGB(p.tint.r, p.tint.g, p.tint.b) - this.setColorAt(index, color) + this.color.setRGB(p.tint.r, p.tint.g, p.tint.b) + this.setColorAt(index, this.color) index++ - if (index >= this._capacity) break + if (index >= this.capacity) break } // Update how many instances to draw From e457370608e790f8ca4f161900bce0ea44205fcd Mon Sep 17 00:00:00 2001 From: nbilyk Date: Sat, 1 Nov 2025 11:25:15 -0700 Subject: [PATCH 6/9] refactor: rename ParticleEmitterQuads --- packages/three-particles/src/object/ParticleEffect.ts | 4 ++-- ...ticleEmitterQuads.ts => ParticleEmitterInstancedMesh.ts} | 6 +++--- packages/three-particles/src/object/index.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) rename packages/three-particles/src/object/{ParticleEmitterQuads.ts => ParticleEmitterInstancedMesh.ts} (92%) diff --git a/packages/three-particles/src/object/ParticleEffect.ts b/packages/three-particles/src/object/ParticleEffect.ts index 17c2ec6..a5df026 100644 --- a/packages/three-particles/src/object/ParticleEffect.ts +++ b/packages/three-particles/src/object/ParticleEffect.ts @@ -1,5 +1,5 @@ import { ParticleEmitterPoints } from './ParticleEmitterPoints' -import { ParticleEmitterQuads } from './ParticleEmitterQuads' +import { ParticleEmitterInstancedMesh } from './ParticleEmitterInstancedMesh' import { Group, PointsMaterial } from 'three' import { isParticleEmitterObject, @@ -50,7 +50,7 @@ export class ParticleEffect extends Group { const usePoints = mat instanceof PointsMaterial const instance = usePoints ? new ParticleEmitterPoints(emitter) - : new ParticleEmitterQuads(emitter) + : new ParticleEmitterInstancedMesh(emitter) this.add(instance) } } diff --git a/packages/three-particles/src/object/ParticleEmitterQuads.ts b/packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts similarity index 92% rename from packages/three-particles/src/object/ParticleEmitterQuads.ts rename to packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts index 7ee9922..01dfd6a 100644 --- a/packages/three-particles/src/object/ParticleEmitterQuads.ts +++ b/packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts @@ -13,10 +13,10 @@ import { ParticleEmitterModel } from '../model' const VEC_Z = new Vector3(0, 0, 1) /** - * ParticleEmitterQuads renders particles as camera-facing quads (via instancing). - * Supports non-uniform scale (scaleX/scaleY) and rotation around Z. + * ParticleEmitterInstancedMesh renders particles an instanced mesh + * Supports transformations per instance. */ -export class ParticleEmitterQuads +export class ParticleEmitterInstancedMesh extends InstancedMesh implements ParticleEmitterObject { diff --git a/packages/three-particles/src/object/index.ts b/packages/three-particles/src/object/index.ts index 785f92b..43b84f8 100644 --- a/packages/three-particles/src/object/index.ts +++ b/packages/three-particles/src/object/index.ts @@ -1,4 +1,4 @@ export * from './ParticleEffect' export * from './ParticleEmitterObject' export * from './ParticleEmitterPoints' -export * from './ParticleEmitterQuads' +export * from './ParticleEmitterInstancedMesh' From 621cd1132aa9b64ec2c5f94afdc5e948fd324271 Mon Sep 17 00:00:00 2001 From: nbilyk Date: Sat, 8 Nov 2025 09:03:51 -0800 Subject: [PATCH 7/9] fix: fix missing property check fix undefined updater when prop is missing --- .../src/state/ParticleState.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/three-particles/src/state/ParticleState.ts b/packages/three-particles/src/state/ParticleState.ts index 8e98f2c..68c3c68 100644 --- a/packages/three-particles/src/state/ParticleState.ts +++ b/packages/three-particles/src/state/ParticleState.ts @@ -176,15 +176,17 @@ class FloatPropertyState implements ParticlePropertyState { private readonly timeline: TimelineModel, ) { this.value = new PropertyValue(timeline) - const prop = timeline.property - if ( - !(prop in particlePropertyUpdaters) && - !missingPropertiesWarned.has(prop) - ) { - missingPropertiesWarned.add(prop) - console.warn( - `Could not find property updater with the name ${prop}`, - ) + const prop = timeline.property as ParticlePropertyKey + if (!(prop in particlePropertyUpdaters)) { + if (!missingPropertiesWarned.has(prop)) { + missingPropertiesWarned.add(prop) + console.warn( + `Could not find property updater with the name ${prop}`, + ) + } + this.updater = () => {} + } else { + this.updater = particlePropertyUpdaters[prop] } this.updater = particlePropertyUpdaters[prop] ?? (() => {}) } From 0652be61831fc6fcbbfad9085af3f05543ec7f6e Mon Sep 17 00:00:00 2001 From: nbilyk Date: Sat, 8 Nov 2025 14:24:12 -0800 Subject: [PATCH 8/9] feat(orientation)!: add orientation and forwardVel rename particle properties for more consistency --- packages/example/resources/fire.json | 12 +- .../src/model/ParticleEmitterModel.ts | 19 +- .../object/ParticleEmitterInstancedMesh.ts | 4 - .../src/state/ParticleState.ts | 186 ++++++++++++------ packages/three-particles/src/util/math.ts | 2 - .../test/model/ParticleEmitterModel.test.ts | 4 +- 6 files changed, 147 insertions(+), 80 deletions(-) diff --git a/packages/example/resources/fire.json b/packages/example/resources/fire.json index 2dd039b..a7b5b56 100644 --- a/packages/example/resources/fire.json +++ b/packages/example/resources/fire.json @@ -107,16 +107,16 @@ }, { "low": { - "min": 2.356, - "max": 0.785, + "min": -1, + "max": 1, "ease": "linear" }, "high": { - "min": 1.571, - "max": 1.571, + "min": 0, + "max": 0, "ease": "linear" }, - "property": "forwardDirectionZ", + "property": "orientationZ", "relative": false, "timeline": [0, 0, 0.409, 1] }, @@ -131,7 +131,7 @@ "max": 1, "ease": "linear" }, - "property": "forwardVelocity", + "property": "forwardVel", "relative": false, "timeline": [0, 1] }, diff --git a/packages/three-particles/src/model/ParticleEmitterModel.ts b/packages/three-particles/src/model/ParticleEmitterModel.ts index 747ac86..36caa0f 100644 --- a/packages/three-particles/src/model/ParticleEmitterModel.ts +++ b/packages/three-particles/src/model/ParticleEmitterModel.ts @@ -61,9 +61,10 @@ export interface ParticleEmitterModel { spawn: Zone /** - * If true, the forward direction affects the rotation. + * If true, the orientation affects the rotation. */ - orientToForwardDirection: boolean + rotateToOrientation: boolean + /** * Timelines relative to the particle life. @@ -145,7 +146,7 @@ export const particleEmitterDefaults = { }, }, spawn: zoneDefaults, - orientToForwardDirection: false, + rotateToOrientation: false, propertyTimelines: [], material: null, } as const satisfies ParticleEmitterModel @@ -188,9 +189,9 @@ export function parseEmitter( emissionRate, particleLifeExpectancy, spawn, - orientToForwardDirection: - emitterJson.orientToForwardDirection ?? - particleEmitterDefaults.orientToForwardDirection, + rotateToOrientation: + emitterJson.rotateToOrientation ?? + particleEmitterDefaults.rotateToOrientation, propertyTimelines, material, } @@ -244,10 +245,10 @@ export function particleEmitterModelToJson( if (Object.keys(spawn).length) out.spawn = spawn if ( - emitter.orientToForwardDirection !== - particleEmitterDefaults.orientToForwardDirection + emitter.rotateToOrientation !== + particleEmitterDefaults.rotateToOrientation ) - out.orientToForwardDirection = emitter.orientToForwardDirection + out.rotateToOrientation = emitter.rotateToOrientation if (emitter.propertyTimelines.length) out.propertyTimelines = emitter.propertyTimelines.map((t) => diff --git a/packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts b/packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts index 01dfd6a..70a1e81 100644 --- a/packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts +++ b/packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts @@ -51,10 +51,6 @@ export class ParticleEmitterInstancedMesh // Position this.obj.position.copy(p.position) - // Rotation around Z (screen-space style). We'll rotate quad around local Z. - this.quat.setFromAxisAngle(VEC_Z, p.rotationFinal.z) - this.obj.quaternion.copy(this.quat) - this.obj.scale.copy(p.scale) this.obj.updateMatrix() diff --git a/packages/three-particles/src/state/ParticleState.ts b/packages/three-particles/src/state/ParticleState.ts index 68c3c68..52bda61 100644 --- a/packages/three-particles/src/state/ParticleState.ts +++ b/packages/three-particles/src/state/ParticleState.ts @@ -1,10 +1,11 @@ -import { Vector3 } from 'three' +import { Vector3, Euler } from 'three' import { clamp } from 'lodash' import { ParticleEmitterModel, randomFromZone, TimelineModel } from '../model' import { PropertyValue } from './PropertyValue' -import { getTimelineValues, HALF_PI } from '../util' +import { closeTo, getTimelineValues } from '../util' const tmpVec = new Vector3() +const tmpEuler = new Euler() /** * Updates the particle state. @@ -18,15 +19,64 @@ export type ParticlePropertyUpdater = ( * Properties of a particle that may be updated. */ export interface ParticleProperties { + /** + * Current position of the particle in 3D space. + */ readonly position: Vector3 + + /** + * Velocity, in units per second. + */ readonly velocity: Vector3 + + /** + * Scale factor applied to the particle size in X, Y, and Z dimensions. + * A value of 1 represents the original size, values greater than 1 increase size, + * and values less than 1 decrease size. + */ readonly scale: Vector3 + + /** + * Rotation around each axis in radians. Positive values rotate clockwise when looking + * along the axis toward the origin. + * - X: Pitch (rotation around X axis) + * - Y: Yaw (rotation around Y axis) + * - Z: Roll (rotation around Z axis) + */ readonly rotation: Vector3 - readonly rotationalVelocity: Vector3 - readonly forwardDirection: Vector3 - readonly forwardDirectionVelocity: Vector3 - forwardVelocity: number + + /** + * Rotational velocity, in radians per second. + */ + readonly rotationVel: Vector3 + + /** + * Orientation angles in radians around each axis. + * Used for determining particle-facing direction. + * + * `forwardVel` will affect movement in this direction. + * This will affect rotation if rotateToOrientation is enabled on the emitter. + */ + readonly orientation: Euler + + /** + * Rate of change for orientation in radians per second. + */ + readonly orientationVel: Vector3 + + /** + * Velocity in the direction the particle is facing, in units per second. + */ + forwardVel: number + + /** + * Color and opacity of the particle. + */ readonly tint: RgbaColor + + /** + * Reference point for particle transformations, typically in normalized coordinates (0-1). + */ readonly origin: Vector3 } @@ -57,10 +107,23 @@ export class ParticleState implements ParticleProperties { readonly velocity = new Vector3() readonly scale = new Vector3(1, 1, 1) readonly rotation = new Vector3() - readonly rotationalVelocity = new Vector3() - readonly forwardDirection = new Vector3() - readonly forwardDirectionVelocity = new Vector3() - forwardVelocity = 0 + readonly rotationVel = new Vector3() + + /** + * The particle's orientation, represented as an Euler object representing + * rotations around each axis, in radians. + */ + readonly orientation = new Euler() + + /** + * The rate of change for orientation. In radians per second. + */ + readonly orientationVel = new Vector3() + + /** + * The forward velocity in the orientation direction, in meters per second. + */ + forwardVel = 0 readonly tint: RgbaColor = new RgbaColor(1, 1, 1, 1) readonly origin = new Vector3(0.5, 0.5, 0.5) @@ -98,31 +161,36 @@ export class ParticleState implements ParticleProperties { } // Scale velocities by tickTime - this.position.add(tmpVec.copy(this.velocity).multiplyScalar(tickTime)) - this.rotation.add( - tmpVec.copy(this.rotationalVelocity).multiplyScalar(tickTime), - ) - this.forwardDirection.add( - tmpVec.copy(this.forwardDirectionVelocity).multiplyScalar(tickTime), - ) - if (this.forwardVelocity !== 0) { - if ( - this.forwardDirection.y !== 0 || - this.forwardDirection.x !== 0 - ) { - // TODO: 3D forward direction. - } else if (this.forwardDirection.z !== 0) { - const theta = this.forwardDirection.z - this.position.x += - Math.cos(theta) * this.forwardVelocity * tickTime - this.position.y += - Math.sin(theta) * this.forwardVelocity * tickTime - } + if (isVec3NotZero(this.velocity)) { + this.position.add( + tmpVec.copy(this.velocity).multiplyScalar(tickTime), + ) + } + + if (isVec3NotZero(this.rotation)) { + this.rotation.add( + tmpVec.copy(this.rotationVel).multiplyScalar(tickTime), + ) } - if (this.model.orientToForwardDirection) { - this.rotationFinal.copy(this.rotation).add(this.forwardDirection) - this.rotationFinal.z += HALF_PI + if (isVec3NotZero(this.orientationVel)) { + // Integrate orientation (Euler angles) by adding scaled angular velocity per axis. + this.orientation.x += this.orientationVel.x * tickTime + this.orientation.y += this.orientationVel.y * tickTime + this.orientation.z += this.orientationVel.z * tickTime + } + + if (!closeTo(this.forwardVel, 0)) { + // Move the particle forward along its orientation by forwardVel per second. + // Compute forward dir by rotating +Z with the current orientation Euler (XYZ order). + tmpVec.set(0, 1, 0).applyEuler(this.orientation) + this.position.addScaledVector(tmpVec, this.forwardVel * tickTime) + } + + if (this.model.rotateToOrientation) { + this.rotationFinal.x = this.rotation.x + this.orientation.x + this.rotationFinal.y = this.rotation.y + this.orientation.y + this.rotationFinal.z = this.rotation.z + this.orientation.z } else { this.rotationFinal.copy(this.rotation) } @@ -137,10 +205,10 @@ export class ParticleState implements ParticleProperties { this.velocity.set(0, 0, 0) this.scale.set(1, 1, 1) this.rotation.set(0, 0, 0) - this.rotationalVelocity.set(0, 0, 0) - this.forwardDirection.set(0, 0, 0) - this.forwardDirectionVelocity.set(0, 0, 0) - this.forwardVelocity = 0 + this.rotationVel.set(0, 0, 0) + this.orientation.set(0, 0, 0) + this.orientationVel.set(0, 0, 0) + this.forwardVel = 0 this.tint.set(1, 1, 1, 1) this.origin.set(0.5, 0.5, 0.5) this.imageIndex = 0 @@ -153,6 +221,7 @@ export class ParticleState implements ParticleProperties { export interface ParticlePropertyState { apply(particleAlpha: number, emitterAlpha: number): void + reset(): void } @@ -188,7 +257,6 @@ class FloatPropertyState implements ParticlePropertyState { } else { this.updater = particlePropertyUpdaters[prop] } - this.updater = particlePropertyUpdaters[prop] ?? (() => {}) } apply(particleAlphaClamped: number, emitterAlphaClamped: number): void { @@ -243,17 +311,14 @@ class ColorPropertyState implements ParticlePropertyState { * A registry of timeline property keys (`TimelineModel.property`) to their respective update * functions. */ -export const particlePropertyUpdaters: Record< - string, - ParticlePropertyUpdater | undefined -> = { +export const particlePropertyUpdaters = { x: (target, value) => (target.position.x = value), y: (target, value) => (target.position.y = value), z: (target, value) => (target.position.z = value), - velocityX: (target, value) => (target.velocity.x = value), - velocityY: (target, value) => (target.velocity.y = value), - velocityZ: (target, value) => (target.velocity.z = value), + xVel: (target, value) => (target.velocity.x = value), + yVel: (target, value) => (target.velocity.y = value), + zVel: (target, value) => (target.velocity.z = value), originX: (target, value) => (target.origin.x = value), originY: (target, value) => (target.origin.y = value), @@ -272,21 +337,19 @@ export const particlePropertyUpdaters: Record< rotationY: (target, value) => (target.rotation.y = value), rotationZ: (target, value) => (target.rotation.z = value), - rotationalVelocityX: (target, value) => - (target.rotationalVelocity.x = value), - rotationalVelocityY: (target, value) => - (target.rotationalVelocity.y = value), - rotationalVelocityZ: (target, value) => - (target.rotationalVelocity.z = value), + rotationXVel: (target, value) => (target.rotationVel.x = value), + rotationYVel: (target, value) => (target.rotationVel.y = value), + rotationZVel: (target, value) => (target.rotationVel.z = value), - forwardDirectionX: (target, value) => (target.forwardDirection.x = value), - forwardDirectionY: (target, value) => (target.forwardDirection.y = value), - forwardDirectionZ: (target, value) => (target.forwardDirection.z = value), + orientationX: (target, value) => (target.orientation.x = value), + orientationY: (target, value) => (target.orientation.y = value), + orientationZ: (target, value) => (target.orientation.z = value), - forwardDirectionVelocityZ: (target, value) => - (target.forwardDirectionVelocity.z = value), + orientationXVel: (target, value) => (target.orientationVel.x = value), + orientationYVel: (target, value) => (target.orientationVel.y = value), + orientationZVel: (target, value) => (target.orientationVel.z = value), - forwardVelocity: (target, value) => (target.forwardVelocity = value), + forwardVel: (target, value) => (target.forwardVel = value), colorR: (target, value) => (target.tint.r = value), colorG: (target, value) => (target.tint.g = value), @@ -294,4 +357,13 @@ export const particlePropertyUpdaters: Record< colorA: (target, value) => (target.tint.a = value), // imageIndex: (target, value) => (target.imageIndex += Math.round(delta)), +} as const satisfies Record + +export type ParticlePropertyKey = keyof typeof particlePropertyUpdaters + +/** + * Returns true if the given vector is not close to 0. + */ +function isVec3NotZero(vec: Vector3): boolean { + return vec.lengthSq() <= Number.EPSILON } diff --git a/packages/three-particles/src/util/math.ts b/packages/three-particles/src/util/math.ts index fc830c3..931f1c4 100644 --- a/packages/three-particles/src/util/math.ts +++ b/packages/three-particles/src/util/math.ts @@ -1,5 +1,3 @@ -export const HALF_PI = Math.PI * 0.5 - /** * Returns true if `x` is within `tolerance` of `y`. * diff --git a/packages/three-particles/test/model/ParticleEmitterModel.test.ts b/packages/three-particles/test/model/ParticleEmitterModel.test.ts index 782eaf0..42b676f 100644 --- a/packages/three-particles/test/model/ParticleEmitterModel.test.ts +++ b/packages/three-particles/test/model/ParticleEmitterModel.test.ts @@ -47,7 +47,7 @@ describe('ParticleEmitterModel', () => { loops: false, count: 5, spawn: { type: 'box', w: 1, h: 2, d: 3 }, - orientToForwardDirection: true, + rotateToOrientation: true, }, {}, ) @@ -57,7 +57,7 @@ describe('ParticleEmitterModel', () => { expect(json.loops).toBe(false) expect(json.count).toBe(5) expect(json.spawn).toBeDefined() - expect(json.orientToForwardDirection).toBe(true) + expect(json.rotateToOrientation).toBe(true) }) it('should convert material object to its id using provided materials record', () => { From dd3b78c08b951b886ecf2f0d8cc670e18d20a9f7 Mon Sep 17 00:00:00 2001 From: nbilyk Date: Sat, 8 Nov 2025 14:25:49 -0800 Subject: [PATCH 9/9] docs: add axes helper to example --- packages/example/resources/fire.json | 320 +++++++++--------- packages/example/src/axesHelper.ts | 58 ++++ packages/example/src/index.ts | 3 + .../src/model/ParticleEmitterModel.ts | 1 - .../object/ParticleEmitterInstancedMesh.ts | 12 +- .../src/state/ParticleState.ts | 3 +- 6 files changed, 227 insertions(+), 170 deletions(-) create mode 100644 packages/example/src/axesHelper.ts diff --git a/packages/example/resources/fire.json b/packages/example/resources/fire.json index a7b5b56..21d916b 100644 --- a/packages/example/resources/fire.json +++ b/packages/example/resources/fire.json @@ -1,160 +1,168 @@ { - "version": "0.3.0", - "materials": { - "points": { - "type": "PointsMaterial", - "size": 0.3, - "sizeAttenuation": true, - "transparent": true, - "map": "radial", - "blending": 2, - "alphaTest": 0.01, - "depthWrite": false, - "vertexColors": true + "version": "0.3.0", + "emitters": [ + { + "uuid": "flame", + "name": "flame", + "duration": { + "duration": { + "min": 4, + "max": 6.5 } - }, - "emitters": [ + }, + "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 + } + }, { - "uuid": "flame", - "enabled": true, - "material": "points", - "loops": true, - "spawn": { - "type": "ellipsoid", - "w": 0.1, - "h": 0, - "d": 0.1 - }, - "duration": { - "duration": { - "min": 4, - "max": 6.5, - "ease": "linear" - }, - "delayBefore": { - "min": 0, - "max": 0, - "ease": "linear" - }, - "delayAfter": { - "min": 0, - "max": 0, - "ease": "linear" - } - }, - "count": 200, - "emissionRate": { - "low": { - "min": 75, - "max": 75, - "ease": "linear" - }, - "high": { - "min": -20, - "max": 15, - "ease": "linear" - }, - "property": "emissionRate", - "relative": true, - "timeline": [0, 0, 0.48, 1, 1, 0] - }, - "particleLifeExpectancy": { - "low": { - "min": 1.6, - "max": 2, - "ease": "linear" - }, - "high": { - "min": 0.8, - "max": 1.6, - "ease": "linear" - }, - "property": "particleLifeExpectancy", - "relative": false, - "timeline": [0.7, 0.2, 1, 1] - }, - "orientToForwardDirection": false, - "propertyTimelines": [ - { - "low": { - "min": 0, - "max": 0, - "ease": "linear" - }, - "high": { - "min": 1, - "max": 1, - "ease": "linear" - }, - "property": "scaleX", - "relative": false, - "timeline": [0.488, 1, 1, 0.472] - }, - { - "low": { - "min": 0, - "max": 0, - "ease": "linear" - }, - "high": { - "min": 1, - "max": 1, - "ease": "linear" - }, - "property": "scaleY", - "relative": false, - "timeline": [0.507, 1, 1, 0.5] - }, - { - "low": { - "min": -1, - "max": 1, - "ease": "linear" - }, - "high": { - "min": 0, - "max": 0, - "ease": "linear" - }, - "property": "orientationZ", - "relative": false, - "timeline": [0, 0, 0.409, 1] - }, - { - "low": { - "min": 0, - "max": 0, - "ease": "linear" - }, - "high": { - "min": 1, - "max": 1, - "ease": "linear" - }, - "property": "forwardVel", - "relative": false, - "timeline": [0, 1] - }, - { - "low": { - "min": 0, - "max": 0, - "ease": "linear" - }, - "high": { - "min": 1, - "max": 1, - "ease": "linear" - }, - "property": "colorA", - "relative": false, - "timeline": [0, 0, 0.171, 1, 0.802, 1, 1, 0] - }, - { - "property": "color", - "timeline": [0, 0.94, 0.165, 0.028, 0.96, 0.51, 0.092, 0] - } - ] + "property": "color", + "timeline": [ + 0, + 0.9399999976158142, + 0.16500000655651093, + 0.02800000086426735, + 1, + 0.5099999904632568, + 0.09200000017881393, + 0 + ], + "high": { + "min": 1 + } } - ] -} + ], + "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/axesHelper.ts b/packages/example/src/axesHelper.ts new file mode 100644 index 0000000..9bcca1d --- /dev/null +++ b/packages/example/src/axesHelper.ts @@ -0,0 +1,58 @@ +import { AxesHelper, CanvasTexture, Scene, Sprite, SpriteMaterial } from 'three' + +export function addAxesHelper(scene: Scene) { + const axisLen = 3 + const axesHelper = new AxesHelper(axisLen) + scene.add(axesHelper) + + // Add axis labels (X, Y, Z) near the ends of the axes + function makeAxisLabel(text: string, color: string) { + const canvas = document.createElement('canvas') + canvas.width = 256 + canvas.height = 128 + const ctx = canvas.getContext('2d')! + + // Background transparent + ctx.clearRect(0, 0, canvas.width, canvas.height) + + // Text styling + ctx.font = '64px sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + // Outline for contrast + ctx.lineWidth = 8 + ctx.strokeStyle = 'black' + ctx.strokeText(text, canvas.width / 2, canvas.height / 2) + + // Fill with axis color + ctx.fillStyle = color + ctx.fillText(text, canvas.width / 2, canvas.height / 2) + + const texture = new CanvasTexture(canvas) + texture.needsUpdate = true + + const material = new SpriteMaterial({ + map: texture, + transparent: true, + depthWrite: false, + }) + const sprite = new Sprite(material) + // Size in world units + sprite.scale.set(0.5, 0.25, 1) + return sprite + } + + const offset = 0.2 + const labelX = makeAxisLabel('X', '#ff5555') + labelX.position.set(axisLen + offset, 0, 0) + scene.add(labelX) + + const labelY = makeAxisLabel('Y', '#55ff55') + labelY.position.set(0, axisLen + offset, 0) + scene.add(labelY) + + const labelZ = makeAxisLabel('Z', '#5555ff') + labelZ.position.set(0, 0, axisLen + offset) + scene.add(labelZ) +} diff --git a/packages/example/src/index.ts b/packages/example/src/index.ts index 4853e17..46f5019 100644 --- a/packages/example/src/index.ts +++ b/packages/example/src/index.ts @@ -10,6 +10,7 @@ import { } from 'three' import { ParticleEffect, ParticleEffectLoader } from 'three-particles' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' +import { addAxesHelper } from './axesHelper' console.log('Hello!') @@ -29,6 +30,8 @@ grid.material.opacity = 0.2 grid.material.transparent = true scene.add(grid) +addAxesHelper(scene) + const canvas = document.querySelector('#mainCanvas') as HTMLCanvasElement const renderer = new WebGLRenderer({ diff --git a/packages/three-particles/src/model/ParticleEmitterModel.ts b/packages/three-particles/src/model/ParticleEmitterModel.ts index 36caa0f..a7e0b2e 100644 --- a/packages/three-particles/src/model/ParticleEmitterModel.ts +++ b/packages/three-particles/src/model/ParticleEmitterModel.ts @@ -65,7 +65,6 @@ export interface ParticleEmitterModel { */ rotateToOrientation: boolean - /** * Timelines relative to the particle life. */ diff --git a/packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts b/packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts index 70a1e81..2ca34e2 100644 --- a/packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts +++ b/packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts @@ -1,17 +1,8 @@ -import { - Color, - InstancedMesh, - Object3D, - PlaneGeometry, - Quaternion, - Vector3, -} from 'three' +import { Color, InstancedMesh, Object3D, PlaneGeometry } from 'three' import { ParticleEmitterState } from '../state' import { ParticleEmitterObject } from './ParticleEmitterObject' import { ParticleEmitterModel } from '../model' -const VEC_Z = new Vector3(0, 0, 1) - /** * ParticleEmitterInstancedMesh renders particles an instanced mesh * Supports transformations per instance. @@ -26,7 +17,6 @@ export class ParticleEmitterInstancedMesh private readonly color = new Color() private readonly capacity: number private readonly obj = new Object3D() - private readonly quat = new Quaternion() constructor(model: ParticleEmitterModel) { const count = model.count diff --git a/packages/three-particles/src/state/ParticleState.ts b/packages/three-particles/src/state/ParticleState.ts index 52bda61..1ccd7f2 100644 --- a/packages/three-particles/src/state/ParticleState.ts +++ b/packages/three-particles/src/state/ParticleState.ts @@ -1,11 +1,10 @@ -import { Vector3, Euler } from 'three' +import { Euler, Vector3 } from 'three' import { clamp } from 'lodash' import { ParticleEmitterModel, randomFromZone, TimelineModel } from '../model' import { PropertyValue } from './PropertyValue' import { closeTo, getTimelineValues } from '../util' const tmpVec = new Vector3() -const tmpEuler = new Euler() /** * Updates the particle state.