From 59b655f4a9394280ba1b932d513d1024c8982c07 Mon Sep 17 00:00:00 2001 From: nbilyk Date: Sat, 8 Nov 2025 20:15:26 -0800 Subject: [PATCH 1/3] feat: add support for bundled materials and geometries [wip] --- packages/example/resources/mesh.json | 107 ++++++++++++ .../resources/meshMaterialExample.json | 158 ------------------ packages/example/src/index.ts | 11 +- .../src/ParticleEffectLoader.ts | 46 +++-- .../src/model/ParticleEffectModel.ts | 109 +++++++++--- .../src/model/ParticleEmitterModel.ts | 66 +++++++- .../object/ParticleEmitterInstancedMesh.ts | 4 +- .../src/object/ParticleEmitterPoints.ts | 2 +- .../test/model/ParticleEffectModel.test.ts | 60 ++++++- .../test/model/ParticleEmitterModel.test.ts | 82 +++++++-- 10 files changed, 422 insertions(+), 223 deletions(-) create mode 100644 packages/example/resources/mesh.json delete mode 100644 packages/example/resources/meshMaterialExample.json diff --git a/packages/example/resources/mesh.json b/packages/example/resources/mesh.json new file mode 100644 index 0000000..90d2e75 --- /dev/null +++ b/packages/example/resources/mesh.json @@ -0,0 +1,107 @@ +{ + "version": "0.3.0", + "emitters": [ + { + "uuid": "flame", + "name": "flame", + "geometry": "cube", + "material": "mesh", + "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 + } + } + ] + } + ] +} diff --git a/packages/example/resources/meshMaterialExample.json b/packages/example/resources/meshMaterialExample.json deleted file mode 100644 index a8d3553..0000000 --- a/packages/example/resources/meshMaterialExample.json +++ /dev/null @@ -1,158 +0,0 @@ -{ - "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/example/src/index.ts b/packages/example/src/index.ts index 46f5019..f0fcb08 100644 --- a/packages/example/src/index.ts +++ b/packages/example/src/index.ts @@ -7,6 +7,8 @@ import { Scene, Vector3, WebGLRenderer, + MeshBasicMaterial, + BoxGeometry, } from 'three' import { ParticleEffect, ParticleEffectLoader } from 'three-particles' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' @@ -57,8 +59,15 @@ function onResize() { // Load the particle effect. let particleEffect: ParticleEffect | null = null const loader = new ParticleEffectLoader() + +// Provide external material and geometry for the effect +loader.setMaterials({ + mesh: new MeshBasicMaterial({ color: 0xffffff, vertexColors: true }), +}) +loader.setGeometries({ cube: new BoxGeometry(0.1, 0.1, 0.1) }) + loader - .loadAsync('./fire.json') + .loadAsync('./mesh.json') .then((model) => { particleEffect = new ParticleEffect(model) scene.add(particleEffect) diff --git a/packages/three-particles/src/ParticleEffectLoader.ts b/packages/three-particles/src/ParticleEffectLoader.ts index 166e647..ed35dfd 100644 --- a/packages/three-particles/src/ParticleEffectLoader.ts +++ b/packages/three-particles/src/ParticleEffectLoader.ts @@ -1,10 +1,12 @@ import { + BufferGeometryLoader, FileLoader, Loader, Material, MaterialLoader, Texture, TextureLoader, + BufferGeometry, } from 'three' import { parseTextureJson } from './parseTextureJson' import { @@ -23,21 +25,26 @@ import { getDefaultRadial } from './materialDefaults' export class ParticleEffectLoader extends Loader { public readonly materialLoader: MaterialLoader public readonly textureLoader: TextureLoader + public readonly geometryLoader: BufferGeometryLoader public materials: Record = {} public textures: Record = {} + public geometries: Record = {} constructor( manager?: LoadingManager, deps?: { readonly materialLoader?: MaterialLoader readonly textureLoader?: TextureLoader + readonly geometryLoader?: BufferGeometryLoader }, ) { super(manager) this.materialLoader = deps?.materialLoader ?? new MaterialLoader(manager) this.textureLoader = deps?.textureLoader ?? new TextureLoader(manager) + this.geometryLoader = + deps?.geometryLoader ?? new BufferGeometryLoader(manager) // 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. @@ -52,10 +59,15 @@ export class ParticleEffectLoader extends Loader { this.textures = textures } + setGeometries(geometries: Record) { + this.geometries = geometries + } + setPath(path: string): this { super.setPath(path) this.materialLoader.setPath(path) this.textureLoader.setPath(path) + this.geometryLoader.setPath(path) return this } @@ -120,12 +132,10 @@ export class ParticleEffectLoader extends Loader { } // 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) + mLoader.setTextures({ ...this.textures, ...bundledTextures }) - // 2) Load bundled materials + // Load bundled materials const bundledMaterials: Record = {} if (json.materials) { for (const [key, material] of Object.entries(json.materials)) { @@ -136,12 +146,26 @@ export class ParticleEffectLoader extends Loader { } } - return parseParticleEffect( - json, - bundledMaterials, - this.materials, - bundledTextures, - externalTextures, - ) + // Load bundled geometries + const bundledGeometries: Record = {} + if (json.geometries) { + const gLoader = this.geometryLoader + for (const [key, geom] of Object.entries(json.geometries)) { + bundledGeometries[key] = + typeof geom === 'string' + ? await gLoader.loadAsync(geom) + : gLoader.parse(geom) + } + } + + return parseParticleEffect({ + effectJson: json, + bundledMaterials: bundledMaterials, + externalMaterials: this.materials, + bundledTextures: bundledTextures, + externalTextures: this.textures, + bundledGeometries: bundledGeometries, + externalGeometries: this.geometries, + }) } } diff --git a/packages/three-particles/src/model/ParticleEffectModel.ts b/packages/three-particles/src/model/ParticleEffectModel.ts index 6ce2b04..6f0c7ff 100644 --- a/packages/three-particles/src/model/ParticleEffectModel.ts +++ b/packages/three-particles/src/model/ParticleEffectModel.ts @@ -11,7 +11,14 @@ import { particleEmitterModelToJson, } from './ParticleEmitterModel' import { PartialDeep } from 'type-fest' -import { Material, MaterialJSON, Texture, TextureJSON } from 'three' +import { + BufferGeometry, + BufferGeometryJSON, + Material, + MaterialJSON, + Texture, + TextureJSON, +} from 'three' /** * A model describing the duration and delay padding for an emitter. @@ -41,6 +48,7 @@ export interface ParticleEffectModel { emitters: ParticleEmitterModel[] materials: Record textures: Record + geometries: Record toJSON(): ParticleEffectModelJson } @@ -54,46 +62,84 @@ export const particleEffectDefaults = { export type ParticleEffectModelJson = Omit< PartialDeep, - 'emitters' | 'materials' + 'emitters' | 'materials' | 'geometries' > & { emitters?: ParticleEmitterModelJson[] - materials?: Record /** - * Allow either TextureJSON blobs or string URLs/keys for textures + * Optional materials bundled with this effect. + * Allow either MaterialJSON blobs or string URLs. + * External materials can be set on the ParticleEffectLoader. + */ + materials?: Record + + /** + * Optional Textures bundled with this effect. + * Allow either TextureJSON blobs or string URLs. + * External textures can be set on the ParticleEffectLoader. */ textures?: Record + + /** + * Optional geometries bundled with this effect. + * Values may be URLs or simple shape descriptors. + * Allow either BufferGeometryJSON blobs or string URLs. + * External geometries can be set on the ParticleEffectLoader. + */ + geometries?: Record } /** * Returns a new ParticleEffectModel with defaults applied. */ -export function parseParticleEffect( - effectJson: ParticleEffectModelJson, - bundledMaterials: Record, - externalMaterials: Record, - bundledTextures: Record, - externalTextures: Record, -): ParticleEffectModel { +export function parseParticleEffect({ + effectJson, + bundledMaterials, + externalMaterials, + bundledTextures, + externalTextures, + bundledGeometries, + externalGeometries, +}: { + effectJson: ParticleEffectModelJson + bundledMaterials: Record + externalMaterials: Record + bundledTextures: Record + externalTextures: Record + bundledGeometries: Record + externalGeometries: Record +}): ParticleEffectModel { const allMaterials = { ...externalMaterials, ...bundledMaterials, } + const allGeometries = { + ...externalGeometries, + ...bundledGeometries, + } const emitters = (effectJson.emitters ?? []) .filter(isNonNil) - .map((emitter) => parseEmitter(emitter, allMaterials)) + .map((emitter) => + parseEmitter({ + emitterJson: emitter, + materials: allMaterials, + geometries: allGeometries, + }), + ) return { version: effectJson.version ?? particleEffectDefaults.version, emitters, materials: bundledMaterials, textures: bundledTextures, + geometries: bundledGeometries, toJSON: function (this: ParticleEffectModel) { - return particleEffectModelToJson( - this, + return particleEffectModelToJson({ + effect: this, externalMaterials, externalTextures, - ) + externalGeometries, + }) }, } } @@ -101,20 +147,30 @@ export function parseParticleEffect( /** * Returns a compact representation of a ParticleEffectModel with default values removed. */ -export function particleEffectModelToJson( - effect: ParticleEffectModel, - externalMaterials: Record, - externalTextures: Record, -): ParticleEffectModelJson { +export function particleEffectModelToJson({ + effect, + externalMaterials, + externalTextures, + externalGeometries, +}: { + effect: ParticleEffectModel + externalMaterials: Record + externalTextures: Record + externalGeometries: Record +}): ParticleEffectModelJson { const out: ParticleEffectModelJson = {} const allMaterials = { ...externalMaterials, ...effect.materials, } + const allGeometries = { + ...externalGeometries, + ...effect.geometries, + } out.version = effect.version if (effect.emitters.length) out.emitters = effect.emitters.map((e) => - particleEmitterModelToJson(e, allMaterials), + particleEmitterModelToJson(e, allMaterials, allGeometries), ) const materialEntries = Object.entries(effect.materials) @@ -127,6 +183,17 @@ export function particleEffectModelToJson( if (Object.keys(materialsJson).length) out.materials = materialsJson } + // Serialize bundled geometries (do not include external geometries) + const geometryEntries = Object.entries(effect.geometries) + if (geometryEntries.length) { + const geometriesJson: Record = {} + for (const [id, geom] of geometryEntries) { + // Use three.js BufferGeometry.toJSON to serialize + geometriesJson[id] = geom.toJSON() as unknown as BufferGeometryJSON + } + if (Object.keys(geometriesJson).length) out.geometries = geometriesJson + } + return out } diff --git a/packages/three-particles/src/model/ParticleEmitterModel.ts b/packages/three-particles/src/model/ParticleEmitterModel.ts index a7e0b2e..c62d0a4 100644 --- a/packages/three-particles/src/model/ParticleEmitterModel.ts +++ b/packages/three-particles/src/model/ParticleEmitterModel.ts @@ -1,5 +1,5 @@ import { EmitterDurationModel } from './ParticleEffectModel' -import { Material, MathUtils } from 'three' +import { BufferGeometry, Material, MathUtils } from 'three' import { parseTimeline, timelineDefaults, @@ -74,6 +74,11 @@ export interface ParticleEmitterModel { * The material(s) to use for this emitter. */ material: Material | Material[] | null + + /** + * The Geometry to use for this emitter. + */ + geometry: BufferGeometry | null } /** @@ -82,7 +87,11 @@ export interface ParticleEmitterModel { */ export type ParticleEmitterModelJson = Omit< PartialDeep, - 'material' | 'propertyTimelines' | 'emissionRate' | 'particleLifeExpectancy' + | 'material' + | 'propertyTimelines' + | 'emissionRate' + | 'particleLifeExpectancy' + | 'geometry' > & { emissionRate?: TimelineModelJson particleLifeExpectancy?: TimelineModelJson @@ -93,6 +102,7 @@ export type ParticleEmitterModelJson = Omit< | Material[] | (string | Material)[] | null + geometry?: string | BufferGeometry | null propertyTimelines?: TimelineModelJson[] } @@ -148,15 +158,21 @@ export const particleEmitterDefaults = { rotateToOrientation: false, propertyTimelines: [], material: null, + geometry: null, } as const satisfies ParticleEmitterModel /** * Returns a new ParticleEmitterModel with defaults applied. */ -export function parseEmitter( - emitterJson: ParticleEmitterModelJson, - materials: Record, -): ParticleEmitterModel { +export function parseEmitter({ + emitterJson, + materials, + geometries, +}: { + emitterJson: ParticleEmitterModelJson + materials: Record + geometries?: Record +}): ParticleEmitterModel { const id = emitterJson.uuid ?? MathUtils.generateUUID() const spawn = parseZone( emitterJson.spawn ?? (cloneDeep(zoneDefaults) as Zone), @@ -177,6 +193,7 @@ export function parseEmitter( .map((t) => parseTimeline(t)) const material = toMaterials(emitterJson.material, materials) + const geometry = toGeometry(emitterJson.geometry, geometries ?? {}) return { uuid: id, @@ -193,6 +210,7 @@ export function parseEmitter( particleEmitterDefaults.rotateToOrientation, propertyTimelines, material, + geometry, } } @@ -218,6 +236,7 @@ export function parseEmitterDuration( export function particleEmitterModelToJson( emitter: ParticleEmitterModel, materials: Record, + geometries: Record = {}, ): Partial { const out: ParticleEmitterModelJson = { uuid: emitter.uuid, @@ -261,6 +280,11 @@ export function particleEmitterModelToJson( } } + if (emitter.geometry != null) { + const geoId = toGeometryId(emitter.geometry, geometries) + if (geoId != null) out.geometry = geoId + } + return out } @@ -348,3 +372,33 @@ export function toMaterialId( console.warn('Missing material id for provided Material') return null } + +/** Geometry helpers **/ +export function toGeometry( + geometryId: Maybe, + geometries: Record, +): BufferGeometry | null { + if (!geometryId) return null + if (typeof geometryId === 'string') { + const geom = geometries[geometryId] + if (!geom) { + console.warn(`Missing geometry: ${geometryId}`) + return null + } + return geom + } + return geometryId +} + +export function toGeometryId( + geometry: Maybe, + geometries: Record, +): string | null { + if (geometry == null) return null + if (typeof geometry === 'string') return geometry + for (const [id, g] of Object.entries(geometries)) { + if (g === geometry) return id + } + console.warn('Missing geometry id for provided BufferGeometry') + return null +} diff --git a/packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts b/packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts index 2ca34e2..16a65d5 100644 --- a/packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts +++ b/packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts @@ -1,4 +1,4 @@ -import { Color, InstancedMesh, Object3D, PlaneGeometry } from 'three' +import { Color, InstancedMesh, Object3D } from 'three' import { ParticleEmitterState } from '../state' import { ParticleEmitterObject } from './ParticleEmitterObject' import { ParticleEmitterModel } from '../model' @@ -21,7 +21,7 @@ export class ParticleEmitterInstancedMesh 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) + super(model.geometry ?? undefined, model.material ?? undefined, count) this.capacity = count this.state = new ParticleEmitterState(model) diff --git a/packages/three-particles/src/object/ParticleEmitterPoints.ts b/packages/three-particles/src/object/ParticleEmitterPoints.ts index a1c2a5b..9bad8bc 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(undefined, model.material ?? undefined) + super(model.geometry ?? undefined, model.material ?? undefined) this.state = new ParticleEmitterState(model) const n = model.count diff --git a/packages/three-particles/test/model/ParticleEffectModel.test.ts b/packages/three-particles/test/model/ParticleEffectModel.test.ts index 09fa9bc..ce1e3e4 100644 --- a/packages/three-particles/test/model/ParticleEffectModel.test.ts +++ b/packages/three-particles/test/model/ParticleEffectModel.test.ts @@ -14,38 +14,76 @@ describe('ParticleEffectModel', () => { describe('parseParticleEffect', () => { it('should set defaults', () => { const effect = {} - const parsed = parseParticleEffect(effect, {}, {}, {}, {}) + const parsed = parseParticleEffect({ + effectJson: effect, + bundledMaterials: {}, + externalMaterials: {}, + bundledTextures: {}, + externalTextures: {}, + bundledGeometries: {}, + externalGeometries: {}, + }) expect(parsed.version).toBeDefined() }) it('should not override set values', () => { const effect = { version: 'v1' } - const parsed = parseParticleEffect(effect, {}, {}, {}, {}) + const parsed = parseParticleEffect({ + effectJson: effect, + bundledMaterials: {}, + externalMaterials: {}, + bundledTextures: {}, + externalTextures: {}, + bundledGeometries: {}, + externalGeometries: {}, + }) expect(parsed.version).toBe('v1') }) }) describe('particleEffectModelToJson', () => { it('should omit defaults', () => { - const parsed = parseParticleEffect({}, {}, {}, {}, {}) - const json = particleEffectModelToJson(parsed, {}, {}) + const parsed = parseParticleEffect({ + effectJson: {}, + bundledMaterials: {}, + externalMaterials: {}, + bundledTextures: {}, + externalTextures: {}, + bundledGeometries: {}, + externalGeometries: {}, + }) + const json = particleEffectModelToJson({ + effect: parsed, + externalMaterials: {}, + externalTextures: {}, + externalGeometries: {}, + }) expect(json).toEqual({ version: '1.0' }) }) it('should include non-default version, emitter json, and materials serialized via toJSON', () => { - const emitter = parseEmitter({ name: 'E', count: 2 }, {}) + const emitter = parseEmitter({ + emitterJson: { name: 'E', count: 2 }, + materials: {}, + }) const mat = new PointsMaterial() const effect: ParticleEffectModel = { version: '2.0', emitters: [emitter], materials: { a: mat }, textures: {}, + geometries: {}, toJSON(): ParticleEffectModelJson { return {} }, } // Pass materials map so emitter/materials serialization can use it - const json = particleEffectModelToJson(effect, { a: mat }, {}) + const json = particleEffectModelToJson({ + effect: effect, + externalMaterials: { a: mat }, + externalTextures: {}, + externalGeometries: {}, + }) expect(json.version).toBe('2.0') expect(Array.isArray(json.emitters)).toBe(true) expect(json.emitters!.length).toBe(1) @@ -66,7 +104,15 @@ describe('ParticleEffectModel', () => { // Added test to ensure toJSON works when spreading a model describe('ParticleEffectModel spread toJSON', () => { it('works when creating a new model using spread', () => { - const base = parseParticleEffect({}, {}, {}, {}, {}) + const base = parseParticleEffect({ + effectJson: {}, + bundledMaterials: {}, + externalMaterials: {}, + bundledTextures: {}, + externalTextures: {}, + bundledGeometries: {}, + externalGeometries: {}, + }) const spread: ParticleEffectModel = { ...base, version: '2.0' } const json = spread.toJSON() expect(json.version).toBe('2.0') diff --git a/packages/three-particles/test/model/ParticleEmitterModel.test.ts b/packages/three-particles/test/model/ParticleEmitterModel.test.ts index 42b676f..41873f1 100644 --- a/packages/three-particles/test/model/ParticleEmitterModel.test.ts +++ b/packages/three-particles/test/model/ParticleEmitterModel.test.ts @@ -8,7 +8,11 @@ import { describe('ParticleEmitterModel', () => { describe('parseEmitter', () => { it('should set count', () => { - const emitter = parseEmitter({}, {}) + const emitter = parseEmitter({ + emitterJson: {}, + materials: {}, + geometries: {}, + }) expect(emitter.count).toBeDefined() }) @@ -18,7 +22,11 @@ describe('ParticleEmitterModel', () => { emissionRate: {}, propertyTimelines: [{}], } - const parsed = parseEmitter(emitter, {}) + const parsed = parseEmitter({ + emitterJson: emitter, + materials: {}, + geometries: {}, + }) expect(parsed.emissionRate.high).toBeDefined() expect(parsed.emissionRate.low).toBeDefined() expect(parsed.emissionRate.high.ease).toBeDefined() @@ -31,7 +39,11 @@ describe('ParticleEmitterModel', () => { describe('particleEmitterModelToJson', () => { it('should omit top-level defaults', () => { - const emitter = parseEmitter({}, {}) + const emitter = parseEmitter({ + emitterJson: {}, + materials: {}, + geometries: {}, + }) const json = particleEmitterModelToJson(emitter, {}) expect(json.name).toBeUndefined() expect(json.enabled).toBeUndefined() @@ -40,8 +52,8 @@ describe('ParticleEmitterModel', () => { }) it('should include non-default fields', () => { - const emitter = parseEmitter( - { + const emitter = parseEmitter({ + emitterJson: { name: 'Test', enabled: false, loops: false, @@ -49,8 +61,10 @@ describe('ParticleEmitterModel', () => { spawn: { type: 'box', w: 1, h: 2, d: 3 }, rotateToOrientation: true, }, - {}, - ) + materials: {}, + + geometries: {}, + }) const json = particleEmitterModelToJson(emitter, {}) expect(json.name).toBe('Test') expect(json.enabled).toBe(false) @@ -64,7 +78,11 @@ describe('ParticleEmitterModel', () => { const matA = new PointsMaterial() const matB = new PointsMaterial() const materials = { a: matA, b: matB } - const emitter = parseEmitter({ material: matA }, materials) + const emitter = parseEmitter({ + emitterJson: { material: matA }, + materials, + geometries: {}, + }) const json = particleEmitterModelToJson(emitter, materials) expect(json.material).toBe('a') }) @@ -73,7 +91,11 @@ describe('ParticleEmitterModel', () => { const matA = new PointsMaterial() const matB = new PointsMaterial() const materials = { a: matA, b: matB } - const emitter = parseEmitter({ material: [matA, 'b'] }, materials) + const emitter = parseEmitter({ + emitterJson: { material: [matA, 'b'] }, + materials, + geometries: {}, + }) const json = particleEmitterModelToJson(emitter, materials) expect(Array.isArray(json.material)).toBe(true) expect(json.material).toEqual(['a', 'b']) @@ -90,7 +112,11 @@ describe('ParticleEmitterModel', () => { it('should omit material and warn', () => { const mat = new PointsMaterial() - const emitter = parseEmitter({ material: mat }, {}) + const emitter = parseEmitter({ + emitterJson: { material: mat }, + materials: {}, + geometries: {}, + }) const json = particleEmitterModelToJson(emitter, {}) expect(json.material).toBeUndefined() expect(console.warn).toHaveBeenCalled() @@ -101,8 +127,16 @@ describe('ParticleEmitterModel', () => { describe('parseEmitter defaults deep clone', () => { it('should deep clone emissionRate and particleLifeExpectancy when using defaults', () => { - const a = parseEmitter({}, {}) - const b = parseEmitter({}, {}) + const a = parseEmitter({ + emitterJson: {}, + materials: {}, + geometries: {}, + }) + const b = parseEmitter({ + emitterJson: {}, + materials: {}, + geometries: {}, + }) // Top-level objects should not be the same as defaults or each other expect(a.emissionRate).not.toBe(particleEmitterDefaults.emissionRate) expect(b.emissionRate).not.toBe(particleEmitterDefaults.emissionRate) @@ -126,8 +160,16 @@ describe('parseEmitter defaults deep clone', () => { }) it('should deep clone duration segments when using defaults', () => { - const a = parseEmitter({}, {}) - const b = parseEmitter({}, {}) + const a = parseEmitter({ + emitterJson: {}, + materials: {}, + geometries: {}, + }) + const b = parseEmitter({ + emitterJson: {}, + materials: {}, + geometries: {}, + }) expect(a.duration).not.toBe(particleEmitterDefaults.duration) expect(b.duration).not.toBe(particleEmitterDefaults.duration) @@ -142,8 +184,16 @@ describe('parseEmitter defaults deep clone', () => { }) it('should deep clone spawn when using defaults', () => { - const a = parseEmitter({}, {}) - const b = parseEmitter({}, {}) + const a = parseEmitter({ + emitterJson: {}, + materials: {}, + geometries: {}, + }) + const b = parseEmitter({ + emitterJson: {}, + materials: {}, + geometries: {}, + }) expect(a.spawn).not.toBe(b.spawn) }) }) From db9a7430061192c555fa7bbd3ce9b4d8d23ebb50 Mon Sep 17 00:00:00 2001 From: nbilyk Date: Sat, 8 Nov 2025 20:19:41 -0800 Subject: [PATCH 2/3] fix: fix incorrect not zero check --- packages/three-particles/src/state/ParticleState.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/three-particles/src/state/ParticleState.ts b/packages/three-particles/src/state/ParticleState.ts index 1ccd7f2..b941c9e 100644 --- a/packages/three-particles/src/state/ParticleState.ts +++ b/packages/three-particles/src/state/ParticleState.ts @@ -364,5 +364,5 @@ 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 + return vec.lengthSq() > Number.EPSILON } From 2dd705292532b1346cc023ba0d6ae4cd24873be3 Mon Sep 17 00:00:00 2001 From: nbilyk Date: Sat, 8 Nov 2025 20:34:27 -0800 Subject: [PATCH 3/3] feat: instanced mesh wip --- packages/example/src/index.ts | 41 +++++++++++++++++-- .../object/ParticleEmitterInstancedMesh.ts | 5 ++- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/example/src/index.ts b/packages/example/src/index.ts index f0fcb08..638b136 100644 --- a/packages/example/src/index.ts +++ b/packages/example/src/index.ts @@ -1,18 +1,23 @@ import { + AmbientLight, + BoxGeometry, Clock, Color, + DirectionalLight, Fog, GridHelper, + Mesh, + MeshStandardMaterial, PerspectiveCamera, + PlaneGeometry, Scene, Vector3, WebGLRenderer, - MeshBasicMaterial, - BoxGeometry, } from 'three' import { ParticleEffect, ParticleEffectLoader } from 'three-particles' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' import { addAxesHelper } from './axesHelper' +import { AdditiveBlending } from 'three/src/constants' console.log('Hello!') @@ -41,6 +46,31 @@ const renderer = new WebGLRenderer({ antialias: true, }) renderer.setClearColor(0x191919) +renderer.shadowMap.enabled = true + +// Lighting +const dirLight = new DirectionalLight(0xffffff, 2) +dirLight.position.set(3, 4, 2) +dirLight.castShadow = true +// Tweak shadow quality and camera bounds to cover our scene area +const s = 5 +dirLight.shadow.camera.left = -s +dirLight.shadow.camera.right = s +dirLight.shadow.camera.top = s +dirLight.shadow.camera.bottom = -s +dirLight.shadow.mapSize.set(1024, 1024) +scene.add(dirLight) +scene.add(new AmbientLight(0xffffff, 0.2)) + +// Ground to receive shadows +const ground = new Mesh( + new PlaneGeometry(20, 20), + new MeshStandardMaterial({ color: 0x303030 }), +) +ground.rotation.x = -Math.PI / 2 +ground.position.y = -0.001 +ground.receiveShadow = true +scene.add(ground) const controls = new OrbitControls(camera, renderer.domElement) @@ -62,7 +92,12 @@ const loader = new ParticleEffectLoader() // Provide external material and geometry for the effect loader.setMaterials({ - mesh: new MeshBasicMaterial({ color: 0xffffff, vertexColors: true }), + mesh: new MeshStandardMaterial({ + color: 0xffffff, + metalness: 0, + roughness: 1, + blending: AdditiveBlending, + }), }) loader.setGeometries({ cube: new BoxGeometry(0.1, 0.1, 0.1) }) diff --git a/packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts b/packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts index 16a65d5..91c91f9 100644 --- a/packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts +++ b/packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts @@ -20,12 +20,15 @@ export class ParticleEmitterInstancedMesh constructor(model: ParticleEmitterModel) { const count = model.count - // Use a simple unit plane. User-provided material is applied below. super(model.geometry ?? undefined, model.material ?? undefined, count) this.capacity = count this.state = new ParticleEmitterState(model) + // Enable shadows by default so lit materials work in demos. + this.castShadow = true + this.receiveShadow = true + // Optionally, set frustumCulled false since particles may be spread. this.frustumCulled = false }