diff --git a/packages/example/resources/mesh.json b/packages/example/resources/mesh.json index 90d2e75..97fbd96 100644 --- a/packages/example/resources/mesh.json +++ b/packages/example/resources/mesh.json @@ -103,5 +103,114 @@ } ] } - ] + ], + + "materials": { + "mesh": { + "type": "MeshStandardMaterial", + "color": 16777215, + "roughness": 1, + "metalness": 0, + "emissive": 0, + "envMapRotation": [0, 0, 0, "XYZ"], + "envMapIntensity": 1, + "blending": 2, + "blendColor": 0 + } + }, + "geometries": { + "cube": { + "metadata": { + "version": 4.7, + "type": "BufferGeometry", + "generator": "BufferGeometry.toJSON" + }, + "uuid": "f84219bf-6602-4909-ae37-7921da261845", + "type": "BufferGeometry", + "data": { + "attributes": { + "position": { + "itemSize": 3, + "type": "Float32Array", + "array": [ + 0.05, 0.05, 0.05, 0.05, -0.05, 0.05, 0.05, 0.05, + -0.05, 0.05, -0.05, 0.05, 0.05, -0.05, -0.05, 0.05, + 0.05, -0.05, -0.05, 0.05, -0.05, -0.05, -0.05, + -0.05, -0.05, 0.05, 0.05, -0.05, -0.05, -0.05, + -0.05, -0.05, 0.05, -0.05, 0.05, 0.05, -0.05, 0.05, + -0.05, -0.05, 0.05, 0.05, 0.05, 0.05, -0.05, -0.05, + 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, -0.05, + -0.05, -0.05, 0.05, -0.05, -0.05, -0.05, 0.05, + -0.05, 0.05, -0.05, -0.05, -0.05, 0.05, -0.05, + -0.05, 0.05, -0.05, 0.05, -0.05, 0.05, 0.05, -0.05, + -0.05, 0.05, 0.05, 0.05, 0.05, -0.05, -0.05, 0.05, + 0.05, -0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, + -0.05, 0.05, -0.05, -0.05, -0.05, 0.05, -0.05, 0.05, + -0.05, -0.05, -0.05, -0.05, -0.05, -0.05, 0.05, + -0.05 + ], + "normalized": false + }, + "normal": { + "itemSize": 3, + "type": "Float32Array", + "array": [ + 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, + 0, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0, 0, + -1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, + 0, 0, 1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0, 0, -1, 0, + 0, -1, 0, 0, -1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, + 1, 0, 0, 1, 0, 0, 1, 0, 0, -1, 0, 0, -1, 0, 0, -1, + 0, 0, -1, 0, 0, -1, 0, 0, -1 + ], + "normalized": false + }, + "uv": { + "itemSize": 2, + "type": "Float32Array", + "array": [ + 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0, 1, + 1, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, + 1, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 1, 1, 0, 1, 0, + 0, 1, 1, 0, 0, 1, 0, 1, 1, 0, 1, 0, 0, 1, 1, 0, 0, + 1, 0, 1, 1 + ], + "normalized": false + } + }, + "groups": [ + { + "start": 0, + "count": 6, + "materialIndex": 0 + }, + { + "start": 6, + "count": 6, + "materialIndex": 1 + }, + { + "start": 12, + "count": 6, + "materialIndex": 2 + }, + { + "start": 18, + "count": 6, + "materialIndex": 3 + }, + { + "start": 24, + "count": 6, + "materialIndex": 4 + }, + { + "start": 30, + "count": 6, + "materialIndex": 5 + } + ] + } + } + } } diff --git a/packages/example/src/axesHelper.ts b/packages/example/src/axesHelper.ts deleted file mode 100644 index 9bcca1d..0000000 --- a/packages/example/src/axesHelper.ts +++ /dev/null @@ -1,58 +0,0 @@ -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 638b136..13288c8 100644 --- a/packages/example/src/index.ts +++ b/packages/example/src/index.ts @@ -1,6 +1,6 @@ import { AmbientLight, - BoxGeometry, + AxesHelper, Clock, Color, DirectionalLight, @@ -16,8 +16,6 @@ import { } 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!') @@ -37,7 +35,7 @@ grid.material.opacity = 0.2 grid.material.transparent = true scene.add(grid) -addAxesHelper(scene) +scene.add(new AxesHelper(3)) const canvas = document.querySelector('#mainCanvas') as HTMLCanvasElement @@ -90,17 +88,6 @@ function onResize() { let particleEffect: ParticleEffect | null = null const loader = new ParticleEffectLoader() -// Provide external material and geometry for the effect -loader.setMaterials({ - mesh: new MeshStandardMaterial({ - color: 0xffffff, - metalness: 0, - roughness: 1, - blending: AdditiveBlending, - }), -}) -loader.setGeometries({ cube: new BoxGeometry(0.1, 0.1, 0.1) }) - loader .loadAsync('./mesh.json') .then((model) => { diff --git a/packages/three-particles/src/object/ParticleEffect.ts b/packages/three-particles/src/object/ParticleEffect.ts index a5df026..29cecd0 100644 --- a/packages/three-particles/src/object/ParticleEffect.ts +++ b/packages/three-particles/src/object/ParticleEffect.ts @@ -60,8 +60,7 @@ export class ParticleEffect extends Group { */ update(dT: number): void { this.forEachEmitter((instance) => { - instance.state.update(dT) - instance.updateGeometry() + instance.update(dT) }) } @@ -69,21 +68,21 @@ export class ParticleEffect extends Group { * Rewinds all emitters. */ rewind(): void { - this.forEachEmitter((instance) => instance.state.rewind()) + this.forEachEmitter((instance) => instance.rewind()) } /** * Stops all emitters */ stop(allowCompletion: boolean): void { - this.forEachEmitter((instance) => instance.state.stop(allowCompletion)) + this.forEachEmitter((instance) => instance.stop(allowCompletion)) } /** * Resets all emitters. */ reset(): void { - this.forEachEmitter((instance) => instance.state.reset()) + this.forEachEmitter((instance) => instance.reset()) } clone(): this { diff --git a/packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts b/packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts index 91c91f9..453a749 100644 --- a/packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts +++ b/packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts @@ -12,7 +12,7 @@ export class ParticleEmitterInstancedMesh implements ParticleEmitterObject { readonly isParticleEmitterObject = true as const - readonly state: ParticleEmitterState + private readonly state: ParticleEmitterState private readonly color = new Color() private readonly capacity: number @@ -33,7 +33,9 @@ export class ParticleEmitterInstancedMesh this.frustumCulled = false } - updateGeometry(): void { + update(dT: number): void { + // progress simulation + this.state.update(dT) if (!this.state.model.enabled) return let index = 0 @@ -44,6 +46,10 @@ export class ParticleEmitterInstancedMesh // Position this.obj.position.copy(p.position) + // Rotation + this.obj.rotation.copy(p.rotationFinal) + + // Scale this.obj.scale.copy(p.scale) this.obj.updateMatrix() @@ -62,4 +68,16 @@ export class ParticleEmitterInstancedMesh this.instanceMatrix.needsUpdate = true if (this.instanceColor) this.instanceColor.needsUpdate = true } + + rewind(): void { + this.state.rewind() + } + + stop(allowCompletion: boolean): void { + this.state.stop(allowCompletion) + } + + reset(): void { + this.state.reset() + } } diff --git a/packages/three-particles/src/object/ParticleEmitterObject.ts b/packages/three-particles/src/object/ParticleEmitterObject.ts index 32ac282..4fdcacd 100644 --- a/packages/three-particles/src/object/ParticleEmitterObject.ts +++ b/packages/three-particles/src/object/ParticleEmitterObject.ts @@ -1,11 +1,10 @@ -import { ParticleEmitterState } from '../state' - export interface ParticleEmitterObject { readonly isParticleEmitterObject: true - readonly state: ParticleEmitterState - - updateGeometry(): void + update(dT: number): void + rewind(): void + stop(allowCompletion: boolean): void + reset(): void } export function isParticleEmitterObject( diff --git a/packages/three-particles/src/object/ParticleEmitterPoints.ts b/packages/three-particles/src/object/ParticleEmitterPoints.ts index 9bad8bc..64acbda 100644 --- a/packages/three-particles/src/object/ParticleEmitterPoints.ts +++ b/packages/three-particles/src/object/ParticleEmitterPoints.ts @@ -13,7 +13,7 @@ export class ParticleEmitterPoints implements ParticleEmitterObject { readonly isParticleEmitterObject = true - readonly state: ParticleEmitterState + private readonly state: ParticleEmitterState constructor(model: ParticleEmitterModel) { super(model.geometry ?? undefined, model.material ?? undefined) @@ -35,13 +35,11 @@ export class ParticleEmitterPoints // this.geometry.boundingSphere = new Sphere(new Vector3(0, 0, 0), 10) } - /** - * Updates this emitter's buffer geometry. - * Typically invoked by the particle effect that owns this emitter. - */ - updateGeometry(): void { + update(dT: number): void { + // Progress internal particle simulation + this.state.update(dT) + // Update geometry buffers from state if (!this.state.model.enabled) return - // Access the typed array for position data: const posArr = this.geometry.attributes.position.array as Float32Array const colorArr = this.geometry.attributes.color.array as Float32Array @@ -67,4 +65,16 @@ export class ParticleEmitterPoints this.geometry.attributes.position.needsUpdate = true this.geometry.attributes.color.needsUpdate = true } + + rewind(): void { + this.state.rewind() + } + + stop(allowCompletion: boolean): void { + this.state.stop(allowCompletion) + } + + reset(): void { + this.state.reset() + } } diff --git a/packages/three-particles/src/state/ParticleState.ts b/packages/three-particles/src/state/ParticleState.ts index b941c9e..998a098 100644 --- a/packages/three-particles/src/state/ParticleState.ts +++ b/packages/three-particles/src/state/ParticleState.ts @@ -42,7 +42,7 @@ export interface ParticleProperties { * - Y: Yaw (rotation around Y axis) * - Z: Roll (rotation around Z axis) */ - readonly rotation: Vector3 + readonly rotation: Euler /** * Rotational velocity, in radians per second. @@ -105,7 +105,7 @@ export class ParticleState implements ParticleProperties { readonly position = new Vector3() readonly velocity = new Vector3() readonly scale = new Vector3(1, 1, 1) - readonly rotation = new Vector3() + readonly rotation = new Euler() readonly rotationVel = new Vector3() /** @@ -127,9 +127,11 @@ export class ParticleState implements ParticleProperties { readonly origin = new Vector3(0.5, 0.5, 0.5) /** - * If orientToForwardDirection is true, the final rotation will be rotation forwardDirection + * The final rotation to apply to the rendered particle. + * If rotateToOrientation is true on the emitter, this is rotation + orientation; + * otherwise equals rotation. */ - readonly rotationFinal = new Vector3() + readonly rotationFinal = new Euler() imageIndex = 0 @@ -166,10 +168,10 @@ export class ParticleState implements ParticleProperties { ) } - if (isVec3NotZero(this.rotation)) { - this.rotation.add( - tmpVec.copy(this.rotationVel).multiplyScalar(tickTime), - ) + if (isVec3NotZero(this.rotationVel)) { + this.rotation.x += this.rotationVel.x * tickTime + this.rotation.y += this.rotationVel.y * tickTime + this.rotation.z += this.rotationVel.z * tickTime } if (isVec3NotZero(this.orientationVel)) { @@ -230,11 +232,13 @@ export function createParticlePropertyState( ): ParticlePropertyState { return timeline.property === 'color' ? new ColorPropertyState(particleProps.tint, timeline) - : new FloatPropertyState(particleProps, timeline) + : new FloatPropertyState( + particleProps, + timeline, + getParticlePropertyUpdater(timeline.property), + ) } -const missingPropertiesWarned = new Set() - class FloatPropertyState implements ParticlePropertyState { private readonly value: PropertyValue private readonly updater: ParticlePropertyUpdater @@ -242,20 +246,10 @@ class FloatPropertyState implements ParticlePropertyState { constructor( private readonly particleProps: ParticleProperties, private readonly timeline: TimelineModel, + updater: ParticlePropertyUpdater, ) { this.value = new PropertyValue(timeline) - 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 = updater } apply(particleAlphaClamped: number, emitterAlphaClamped: number): void { @@ -360,6 +354,24 @@ export const particlePropertyUpdaters = { export type ParticlePropertyKey = keyof typeof particlePropertyUpdaters +const missingPropertiesWarned = new Set() + +export function getParticlePropertyUpdater( + propertyKey: string, +): ParticlePropertyUpdater { + const prop = propertyKey as ParticlePropertyKey + if (!(prop in particlePropertyUpdaters)) { + if (!missingPropertiesWarned.has(prop)) { + missingPropertiesWarned.add(prop) + console.warn( + `Could not find property updater with the name ${prop}`, + ) + } + return () => {} + } + return particlePropertyUpdaters[prop] +} + /** * Returns true if the given vector is not close to 0. */ diff --git a/packages/three-particles/src/util/interpolation.ts b/packages/three-particles/src/util/interpolation.ts index d34b9f2..2e86229 100644 --- a/packages/three-particles/src/util/interpolation.ts +++ b/packages/three-particles/src/util/interpolation.ts @@ -11,10 +11,21 @@ export function getTimelineValue( timeline: ArrayLike, time: number, ): number { + const len = timeline.length + if (len === 0) return 0 + const stride = 2 + const lastTimeIndex = len - stride + const firstTime = timeline[0] + const lastTime = timeline[lastTimeIndex] + + // Clamp before/after range + if (time <= firstTime) return timeline[1] + if (time >= lastTime) return timeline[lastTimeIndex + 1] + let indexB = getInsertionIndex(timeline, time, stride) const indexA = Math.max(0, indexB - stride) - if (indexB === timeline.length) indexB -= stride + if (indexB === len) indexB -= stride const timeA = timeline[indexA] const timeB = timeline[indexB] @@ -37,14 +48,30 @@ export function getTimelineValues( time: number, out: Float32Array, ): void { - if (timeline.length === 0) { + const len = timeline.length + if (len === 0) { out.fill(0, 0, numComponents) return } const stride = numComponents + 1 + const lastTimeIndex = len - stride + const firstTime = timeline[0] + const lastTime = timeline[lastTimeIndex] + + // Clamp before/after range + if (time <= firstTime) { + for (let i = 0; i < numComponents; i++) out[i] = timeline[1 + i] + return + } + if (time >= lastTime) { + for (let i = 0; i < numComponents; i++) + out[i] = timeline[lastTimeIndex + 1 + i] + return + } + let indexB = getInsertionIndex(timeline, time, stride) const indexA = Math.max(0, indexB - stride) - if (indexB === timeline.length) indexB -= stride + if (indexB === len) indexB -= stride const timeA = timeline[indexA] const timeB = timeline[indexB] @@ -88,9 +115,12 @@ export function getIndexClosestToTime( numComponents: number, time: number, ): number { + const len = timeline.length + if (len === 0) return -1 const stride = numComponents + 1 - const a = getInsertionIndex(timeline, time, stride) + let a = getInsertionIndex(timeline, time, stride) if (a <= 0) return 0 + if (a >= len) a = len - stride const b = a - stride const diffA = Math.abs(time - timeline[a]) const diffB = Math.abs(time - timeline[b]) diff --git a/packages/three-particles/test/state/ParticleState.test.ts b/packages/three-particles/test/state/ParticleState.test.ts new file mode 100644 index 0000000..b0f8bbf --- /dev/null +++ b/packages/three-particles/test/state/ParticleState.test.ts @@ -0,0 +1,230 @@ +import { Euler, Vector3 } from 'three' +import { + parseEmitter, + type ParticleEmitterModelJson, +} from '../../src/model/ParticleEmitterModel' +import { + ParticleState, + getParticlePropertyUpdater, + particlePropertyUpdaters, + RgbaColor, + type ParticleProperties, +} from '../../src/state/ParticleState' + +function emitterWithTimelines( + timelines: { + property: string + timeline: number[] + useEmitterDuration?: boolean + }[], + rotateToOrientation = false, +) { + const json: ParticleEmitterModelJson = { + uuid: 'test', + name: 'e', + enabled: true, + loops: true, + count: 1, + duration: { duration: { min: 1, max: 1, ease: 'linear' } }, + emissionRate: { + property: 'emissionRate', + useEmitterDuration: true, + low: { min: 1, max: 1, ease: 'linear' }, + high: { min: 1, max: 1, ease: 'linear' }, + }, + particleLifeExpectancy: { + property: 'particleLifeExpectancy', + useEmitterDuration: true, + low: { min: 1, max: 1, ease: 'linear' }, + high: { min: 1, max: 1, ease: 'linear' }, + }, + spawn: { + type: 'point', + x: 0, + y: 0, + z: 0, + w: 0, + h: 0, + d: 0, + ease: 'linear', + }, + rotateToOrientation, + propertyTimelines: timelines.map((t) => ({ + property: t.property, + timeline: new Float32Array(t.timeline), + useEmitterDuration: !!t.useEmitterDuration, + })), + material: null, + geometry: null, + } + return parseEmitter({ emitterJson: json, materials: {} }) +} + +function makeProps(): ParticleProperties { + return { + position: new Vector3(), + velocity: new Vector3(), + scale: new Vector3(1, 1, 1), + rotation: new Euler(), + rotationVel: new Vector3(), + orientation: new Euler(), + orientationVel: new Vector3(), + forwardVel: 0, + tint: new RgbaColor(1, 1, 1, 1), + origin: new Vector3(0.5, 0.5, 0.5), + } +} + +describe('ParticleState', () => { + it('applies float timelines and integrates velocity over tickTime', () => { + const emitter = emitterWithTimelines([ + { property: 'x', timeline: [0, 0, 1, 10] }, // x goes 0 -> 10 + { property: 'zVel', timeline: [0, 2, 1, 2] }, // vel.z constant 2 + ]) + const p = new ParticleState(emitter) + p.reset() + p.lifeExpectancy = 1 // alpha = life + + // advance by 0.5s, alpha=0.5 + p.update(0.5, 0) + // x from timeline = 5, then velocity integration adds to position.z not x + expect(p.position.x).toBeCloseTo(5) + // position.z should have moved by vel.z * dt = 2 * 0.5 = 1 + expect(p.position.z).toBeCloseTo(1) + }) + + it('uses emitter duration when timeline.useEmitterDuration=true', () => { + const emitter = emitterWithTimelines([ + { property: 'y', timeline: [0, 0, 1, 8], useEmitterDuration: true }, + ]) + const p = new ParticleState(emitter) + p.lifeExpectancy = 10 + p.reset() + + // with tickTime 0, particle alpha is 0 but emitter alpha drives the timeline + p.update(0, 0.25) + expect(p.position.y).toBeCloseTo(2) // 0.25 of 0->8 + }) + + it('moves forward along the oriented forward direction and sets rotationFinal based on rotateToOrientation', () => { + const emitter = emitterWithTimelines( + [ + { property: 'forwardVel', timeline: [0, 2, 1, 2] }, // constant 2 m/s + { property: 'rotationZ', timeline: [0, 0.2, 1, 0.2] }, + { property: 'orientationZ', timeline: [0, 0.3, 1, 0.3] }, + ], + true, + ) + const p = new ParticleState(emitter) + p.lifeExpectancy = 1 + p.reset() + + // advance half a second + p.update(0.5, 0) + + // forward vector starts along +Y, with orientationZ=0.3 rad it's rotated in XY plane + // displacement magnitude should be forwardVel * dt = 1 + const displacement = new Vector3().copy(p.position) + expect(displacement.length()).toBeCloseTo(1) + + // rotationFinal should be rotation + orientation when rotateToOrientation=true + expect(p.rotationFinal.z).toBeCloseTo(0.2 + 0.3) + }) + + it('when rotateToOrientation=false, rotationFinal equals rotation', () => { + const emitter = emitterWithTimelines( + [ + { property: 'rotationX', timeline: [0, 0.1, 1, 0.1] }, + { property: 'orientationX', timeline: [0, 0.4, 1, 0.4] }, + ], + false, + ) + const p = new ParticleState(emitter) + p.lifeExpectancy = 1 + p.reset() + p.update(0.5, 0) + expect(p.rotationFinal.x).toBeCloseTo(0.1) + }) + + it('applies color timeline; after reset tint is default until first update applies timeline', () => { + const emitter = emitterWithTimelines([ + // color timeline: time stride of 4 (t, r, g, b) + { property: 'color', timeline: [0, 1, 0, 0, 1, 0, 1, 0] }, + ]) + const p = new ParticleState(emitter) + p.reset() + p.lifeExpectancy = 1 + + // After reset, tint is set to defaults (1,1,1,1) until first update applies timeline + expect(p.tint.r).toBeCloseTo(1) + expect(p.tint.g).toBeCloseTo(1) + expect(p.tint.b).toBeCloseTo(1) + + p.update(0.5, 0) + expect(p.tint.r).toBeCloseTo(0.5) + expect(p.tint.g).toBeCloseTo(0.5) + expect(p.tint.b).toBeCloseTo(0) + + // change current tint then reset and ensure it goes back to defaults, then apply timeline again + p.tint.set(0, 0, 0, 1) + p.reset() + expect(p.tint.r).toBeCloseTo(1) + expect(p.tint.g).toBeCloseTo(1) + expect(p.tint.b).toBeCloseTo(1) + p.update(0, 0) + expect(p.tint.r).toBeCloseTo(1) + expect(p.tint.g).toBeCloseTo(0) + expect(p.tint.b).toBeCloseTo(0) + }) +}) + +describe('ParticleState rotation integration', () => { + it('integrates rotationVel even when initial rotation is zero', () => { + const emitter = emitterWithTimelines([ + { property: 'rotationZVel', timeline: [0, 2, 1, 2] }, + ]) + const p = new ParticleState(emitter) + p.reset() + p.lifeExpectancy = 1 + + expect(p.rotation.z).toBe(0) + // tick 0.5s with rotationZVel = 2 rad/s + p.update(0.5, 0) + expect(p.rotation.z).toBeCloseTo(1) + }) +}) + +describe('getParticlePropertyUpdater', () => { + it('returns working updater for known keys', () => { + const keys = Object.keys(particlePropertyUpdaters) + expect(keys.length).toBeGreaterThan(0) + const props = makeProps() + const updater = getParticlePropertyUpdater('x') + updater(props, 3.14) + expect(props.position.x).toBeCloseTo(3.14) + + const updVel = getParticlePropertyUpdater('zVel') + updVel(props, 2) + expect(props.velocity.z).toBeCloseTo(2) + + const updColor = getParticlePropertyUpdater('colorA') + updColor(props, 0.25) + expect(props.tint.a).toBeCloseTo(0.25) + }) + + it('returns no-op and warns exactly once for unknown key', () => { + const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + const props = makeProps() + const u1 = getParticlePropertyUpdater('doesNotExist') + const u2 = getParticlePropertyUpdater('doesNotExist') + + // both should be no-ops + props.position.x = 1 + u1(props, 5) + expect(props.position.x).toBe(1) + u2(props, 10) + expect(props.position.x).toBe(1) + + expect(spy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/three-particles/test/util/interpolation.test.ts b/packages/three-particles/test/util/interpolation.test.ts index da44c8c..896bd16 100644 --- a/packages/three-particles/test/util/interpolation.test.ts +++ b/packages/three-particles/test/util/interpolation.test.ts @@ -66,4 +66,51 @@ describe('util/interpolation', () => { expect(getIndexCloseToTime(tl, 1, 1.1, 0.05)).toBe(-1) }) }) + + it('getIndexClosestToTime: returns last key index when time is after the final key', () => { + // stride = 2 (numComponents=1) + const tl = new Float32Array([0, 0, 1, 10, 2, 20]) + const idx = getIndexClosestToTime(tl, 1, 10) + // last time index should be 4 + expect(idx).toBe(4) + }) + + it('getIndexClosestToTime: returns 0 when time is before the first key', () => { + const tl = new Float32Array([0, 0, 1, 10, 2, 20]) + const idx = getIndexClosestToTime(tl, 1, -5) + expect(idx).toBe(0) + }) + + it('getIndexClosestToTime: returns -1 for empty timeline', () => { + const tl = new Float32Array([]) + const idx = getIndexClosestToTime(tl, 1, 0.5) + expect(idx).toBe(-1) + }) + + it('getIndexCloseToTime: returns -1 for empty timeline', () => { + const tl = new Float32Array([]) + const idx = getIndexCloseToTime(tl, 1, 0.1, 0.05) + expect(idx).toBe(-1) + }) + + it('getTimelineValue: returns 0 for empty timeline', () => { + const v = getTimelineValue(new Float32Array([]), 0.5) + expect(v).toBe(0) + }) + + it('getTimelineValue: clamps before first and after last keys', () => { + const tl = new Float32Array([0, 0, 1, 10, 2, 20]) + expect(getTimelineValue(tl, -10)).toBe(0) + expect(getTimelineValue(tl, 10)).toBe(20) + }) + + it('getTimelineValues: clamps before first and after last keys', () => { + // numComponents = 3 + const tl = new Float32Array([0, 0, 10, 20, 2, 20, 30, 40]) + const out = new Float32Array(3) + getTimelineValues(tl, 3, -1, out) + expect(Array.from(out)).toEqual([0, 10, 20]) + getTimelineValues(tl, 3, 5, out) + expect(Array.from(out)).toEqual([20, 30, 40]) + }) })