diff --git a/packages/example/resources/diamond.png b/packages/example/resources/diamond.png new file mode 100644 index 0000000..226b4f2 Binary files /dev/null and b/packages/example/resources/diamond.png differ diff --git a/packages/example/resources/fire.json b/packages/example/resources/fire.json index 53f8dd3..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": 6, - "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": 2, - "h": 0, - "d": 2 - }, - "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": 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": 12, - "max": 12, - "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] - } - ] + "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/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/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 45ca4fc..46f5019 100644 --- a/packages/example/src/index.ts +++ b/packages/example/src/index.ts @@ -1,33 +1,40 @@ -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' +import { addAxesHelper } from './axesHelper' 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 PerspectiveCamera() +camera.position.set(0, 1, 2) -camera.lookAt(new THREE.Vector3(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, 20, 100) +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(200, 40, 0x000000, 0xffffff) +const grid = new GridHelper(20, 20, 0x000000, 0xffffff) grid.material.opacity = 0.2 grid.material.transparent = true scene.add(grid) +addAxesHelper(scene) + const canvas = document.querySelector('#mainCanvas') as HTMLCanvasElement -const renderer = new THREE.WebGLRenderer({ +const renderer = new WebGLRenderer({ canvas, antialias: true, }) 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/model/ParticleEmitterModel.ts b/packages/three-particles/src/model/ParticleEmitterModel.ts index 747ac86..a7e0b2e 100644 --- a/packages/three-particles/src/model/ParticleEmitterModel.ts +++ b/packages/three-particles/src/model/ParticleEmitterModel.ts @@ -61,9 +61,9 @@ 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 +145,7 @@ export const particleEmitterDefaults = { }, }, spawn: zoneDefaults, - orientToForwardDirection: false, + rotateToOrientation: false, propertyTimelines: [], material: null, } as const satisfies ParticleEmitterModel @@ -188,9 +188,9 @@ export function parseEmitter( emissionRate, particleLifeExpectancy, spawn, - orientToForwardDirection: - emitterJson.orientToForwardDirection ?? - particleEmitterDefaults.orientToForwardDirection, + rotateToOrientation: + emitterJson.rotateToOrientation ?? + particleEmitterDefaults.rotateToOrientation, propertyTimelines, material, } @@ -244,10 +244,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/ParticleEffect.ts b/packages/three-particles/src/object/ParticleEffect.ts index 0893cbc..a5df026 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 { ParticleEmitterInstancedMesh } from './ParticleEmitterInstancedMesh' +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 ParticleEmitterInstancedMesh(emitter) this.add(instance) } } diff --git a/packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts b/packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts new file mode 100644 index 0000000..2ca34e2 --- /dev/null +++ b/packages/three-particles/src/object/ParticleEmitterInstancedMesh.ts @@ -0,0 +1,62 @@ +import { Color, InstancedMesh, Object3D, PlaneGeometry } from 'three' +import { ParticleEmitterState } from '../state' +import { ParticleEmitterObject } from './ParticleEmitterObject' +import { ParticleEmitterModel } from '../model' + +/** + * ParticleEmitterInstancedMesh renders particles an instanced mesh + * Supports transformations per instance. + */ +export class ParticleEmitterInstancedMesh + extends InstancedMesh + implements ParticleEmitterObject +{ + readonly isParticleEmitterObject = true as const + readonly state: ParticleEmitterState + + private readonly color = new Color() + private readonly capacity: number + private readonly obj = new Object3D() + + 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 + + let index = 0 + + for (const p of this.state.particles) { + if (!p.active) continue + + // Position + this.obj.position.copy(p.position) + + this.obj.scale.copy(p.scale) + + this.obj.updateMatrix() + this.setMatrixAt(index, this.obj.matrix) + + // Instance color (RGB). Alpha is not supported per-instance on standard materials. + this.color.setRGB(p.tint.r, p.tint.g, p.tint.b) + this.setColorAt(index, this.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/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/index.ts b/packages/three-particles/src/object/index.ts index 5b28bce..43b84f8 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 './ParticleEmitterInstancedMesh' 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/src/state/ParticleState.ts b/packages/three-particles/src/state/ParticleState.ts index 8e98f2c..1ccd7f2 100644 --- a/packages/three-particles/src/state/ParticleState.ts +++ b/packages/three-particles/src/state/ParticleState.ts @@ -1,8 +1,8 @@ -import { Vector3 } from 'three' +import { Euler, Vector3 } 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() @@ -18,15 +18,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 +106,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 +160,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 (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 (this.model.orientToForwardDirection) { - this.rotationFinal.copy(this.rotation).add(this.forwardDirection) - this.rotationFinal.z += HALF_PI + 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 +204,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 +220,7 @@ export class ParticleState implements ParticleProperties { export interface ParticlePropertyState { apply(particleAlpha: number, emitterAlpha: number): void + reset(): void } @@ -176,17 +244,18 @@ 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] ?? (() => {}) } apply(particleAlphaClamped: number, emitterAlphaClamped: number): void { @@ -241,17 +310,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), @@ -270,21 +336,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), @@ -292,4 +356,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', () => { 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) + }) +})