Skip to content

Commit 4eb2510

Browse files
authored
Merge pull request #35 from NewKrok/claude/fix-world-simulation-CfOH4
test: add world-space simulation invariant tests
2 parents 9bcb62c + 8b9d887 commit 4eb2510

41 files changed

Lines changed: 7415 additions & 845 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/doc/architecture.md

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,8 @@ createParticleSystem(config, externalNow)
149149
│ ├─ Blending mode, depth settings, transparency
150150
│ └─ Texture (user-provided, default white circle for POINTS/INSTANCED, or solid white 1×1 for MESH)
151151
152-
├─ 5. Create THREE.Points / THREE.Mesh (INSTANCED/MESH) (+ Gyroscope wrapper for WORLD space)
152+
├─ 5. Create THREE.Points / THREE.Mesh (INSTANCED/MESH)
153+
│ For WORLD mode: matrixWorldAutoUpdate = false, matrixWorld held at identity
153154
154155
└─ 6. Push to createdParticleSystems[], return ParticleSystem handle
155156
```
@@ -176,7 +177,8 @@ updateParticleSystems({ now, delta, elapsed })
176177
│ │
177178
│ ├─ Update position:
178179
│ │ position += velocity * delta
179-
│ │ (+ worldPositionChange compensation for WORLD space)
180+
│ │ (WORLD space: positions are stored in world coords,
181+
│ │ so no per-frame emitter-motion compensation is needed)
180182
│ │
181183
│ ├─ Apply modifiers (if active):
182184
│ │ ├─ Linear velocity
@@ -394,16 +396,45 @@ LifetimeCurve → evaluate curve at time, multiply by scale
394396
## World vs Local Simulation Space
395397

396398
### Local Space (default)
397-
- Particles are children of the emitter
398-
- Moving the emitter moves all particles with it
399-
- No position compensation needed
400-
- Uses `THREE.Points` directly
399+
- Particles are children of the emitter in the Three.js scene graph
400+
- Moving or rotating the emitter moves/rotates all particles with it
401+
- Buffer stores positions in the emitter's local frame
402+
- Gravity and force field positions/directions are CPU-transformed into the emitter's local frame each frame so they stay world-aligned
401403

402404
### World Space
403-
- Particles stay fixed in world coordinates after emission
404-
- Emitter movement tracked via `Gyroscope` (from `@newkrok/three-utils`)
405-
- Each frame: `position -= worldPositionChange` to counteract emitter movement
406-
- Useful for: trails, smoke, fire that should persist in place
405+
- Buffer stores positions in **world coordinates** directly
406+
- `particleSystem.matrixWorld` is held at identity (via `matrixWorldAutoUpdate = false`) so the buffer renders as-is
407+
- `generalData.sourceWorldMatrix` captures the emitter pose each frame
408+
(`parent.matrixWorld × particleSystem.matrix`) — used only to place new
409+
particles and orient the emission shape; existing particles are not moved
410+
- No per-frame position compensation is required
411+
- Gravity is applied as a constant world vector; force field positions and directions flow through unchanged
412+
- `instance.position` / `instance.rotation` (Option 2 semantics, matching Unity)
413+
offset the spawn origin under the parent but do not drag already-emitted particles
414+
415+
### Parent-scale semantics (Unity Shape-module parity)
416+
- `generalData.worldScale` is decomposed from the emitter's full world
417+
transform each frame (both WORLD and LOCAL modes).
418+
- **WORLD mode — spawn offsets scale with the parent.** The shape-emission
419+
offset (`startPositions[i]`) is multiplied by `worldScale` before being
420+
added to the emitter's world translation. This matches Unity's Shape
421+
module with `Scaling Mode = Local/Hierarchy`: a sphere of radius 1 under
422+
a ×3 parent spawns particles on a world-radius-3 sphere. **Already-emitted
423+
particles are unaffected** by subsequent scale changes.
424+
- **LOCAL mode — gravity is divided by parent scale.** Gravity is authored
425+
in world m/s² (`-9.81` fall etc.) but the LOCAL buffer stores velocity in
426+
the emitter's local units. Dividing `gravityVelocity` by `worldScale`
427+
keeps the rendered fall constant in world units regardless of how the
428+
parent chain scales — matching Unity, whose `Physics.gravity` is a world
429+
constant.
430+
- **Design rationale for `matrixWorldAutoUpdate = false` (WORLD mode).**
431+
The alternative — re-parenting the `THREE.Points` to the scene root —
432+
would hide the particle system from the user's `instance.parent`
433+
references and break any scene traversal that expects the instance to
434+
live under its intended parent. Forcing `matrixWorld = identity` keeps
435+
the object tree intact (so Three.js frustum culling, raycasting, etc.
436+
still see the particles under their owning parent) while decoupling the
437+
particle render transform from the emitter's motion.
407438

408439
---
409440

@@ -476,7 +507,7 @@ All fields optional — merged with defaults at creation:
476507

477508
```typescript
478509
{
479-
instance: THREE.Points | THREE.Mesh | Gyroscope // Add to scene (type depends on renderer + simulation space)
510+
instance: THREE.Points | THREE.Mesh // Add to scene (type depends on renderer)
480511
update(cycleData) // Call every frame
481512
dispose() // Cleanup
482513
pauseEmitter() // Stop emitting

CHANGELOG.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,38 @@ All notable changes to this project will be documented in this file.
44

55
This project adheres to [Semantic Versioning](https://semver.org/) and uses [Conventional Commits](https://www.conventionalcommits.org/).
66

7+
## [Unreleased]
8+
9+
### Changed (BREAKING)
10+
11+
- **`SimulationSpace.WORLD` rewritten** to follow Unity's world-simulation-space model. The previous implementation wrapped the particle system in a `three/examples` `Gyroscope`, cancelled the parent rotation, and compensated buffer positions each frame by subtracting the emitter's world movement. The new path stores world coordinates directly in the particle buffer and holds `particleSystem.matrixWorld` at identity so rendering is decoupled from the emitter's scene-graph transform.
12+
- **Fixed** a gravity direction bug in WORLD mode: gravity is now applied as a constant world vector. Previously it was derived from the emitter's world position treated as a direction vector, causing particles to drift sideways or upward when the emitter moved or rotated.
13+
- **Fixed** a directional force field bug in WORLD mode: field directions now stay world-aligned regardless of emitter rotation. Previously the direction was pre-rotated by `particleSystem.getWorldQuaternion()`, which did not invoke the Gyroscope's rotation cancellation and therefore picked up the parent rotation.
14+
- **Fixed** frame-lag jitter on fast-moving emitters (no more `worldPositionChange` subtraction in the integrator, on either the CPU or the GPU compute path).
15+
- **Fixed** death/birth sub-emitter spawn position on moving emitters: the sub-emitter now spawns at the parent particle's world location, not at the parent emitter's current world position.
16+
- **Fixed** LOCAL-mode gravity magnitude under scaled parents: gravity is now divided by the emitter's world scale so the rendered fall matches world m/s² regardless of parent scale (Unity parity).
17+
- **Fixed** WORLD-mode shape-emission spawn offsets not honouring parent scale. Spawn offsets are now multiplied by the emitter's world scale (Unity Shape-module parity with `Scaling Mode = Local/Hierarchy`); live particles remain unaffected by post-spawn scale changes.
18+
- **Fixed** a stale-matrix bug in LOCAL-mode sub-emitter death/birth callbacks: `particleSystem.updateMatrixWorld()` is now called before `localToWorld(...)` so the sub-emitter spawns at the parent's current world position.
19+
- **Fixed** a silent CPU→GPU upload regression: the `positionNeedsUpdate` guard no longer flags re-uploads for stationary particles when the emitter moves (`worldPositionChange` conditions removed from the guard, matching the fact that the compensation subtraction was already gone).
20+
- **Color pipeline standardised to the three.js linear workflow.** User color inputs (`startColor`, `backgroundColor`) and color map textures are now treated as sRGB — the same convention every other three.js material uses. The library decodes to linear on input, shaders operate in linear, and the renderer's output pass converts back to sRGB on the way to the framebuffer. Previously the library wrote raw values to the framebuffer and required consumers to set `renderer.outputColorSpace = LinearSRGBColorSpace`, which broke every non-particle material in the same scene.
21+
- GLSL fragment shaders (`particle-system`, `instanced-particle`, `mesh-particle`, `trail`) now include `<colorspace_fragment>` so they participate in the renderer's standard color-space conversion.
22+
- TSL materials no longer force `map.colorSpace = NoColorSpace`; user-tagged sRGB textures get the hardware decode the rest of three.js expects.
23+
- Per-particle color buffer writes now apply `sRGBToLinear` on user `startColor` values. `colorOverLifetime` multipliers are scalars and keep their existing semantics (applied in linear space).
24+
- `backgroundColor` uniforms are converted to linear on upload so `discardBackgroundColor` compares against the (now linear) texture sample on equal footing.
25+
- **Fixed** `discardBackgroundColor` not firing in WebGPU TSL materials. `Discard()` was wrapped inside a TSL `Fn` helper, which prevented the fragment `discard` statement from propagating to the main shader — so black-background cutouts silently stopped working on the POINTS, INSTANCED, and MESH renderers (Shield, Fireworks, Magnetic Field, Implosion, Explosion with Smoke, etc.).
26+
- **Fixed** `updateConfig({ simulationSpace })` leaving the system in an inconsistent state. Live-switching simulation space now deactivates existing particles (their buffer positions are in the old frame and would render at random locations) and flips `matrixWorldAutoUpdate` to match what `createParticleSystem` would have set for the new frame. Previously the simulationSpace scalar was updated but the buffer and `matrixWorld` flags were not, causing particles to snap between origins and jump around after a LOCAL↔WORLD toggle.
27+
28+
### Migration
29+
30+
- `ParticleSystem.instance` is now always `THREE.Points | THREE.Mesh` — the union with `Gyroscope` is gone. Any code that checked `instance instanceof Gyroscope` or reached into `instance.children[0]` to find the inner `Points` must use `instance` directly.
31+
- `ParticleSystemInstance.wrapper` is removed.
32+
- In WORLD mode, `instance.matrixWorld` is identity. The emitter's pose is still read from the parent chain for emission; `instance.position` / `instance.rotation` act as a spawn-origin offset under the parent and do **not** drag already-emitted particles (matches Unity).
33+
- The dependency on `three/examples/jsm/misc/Gyroscope.js` is removed.
34+
- WebGPU-internal uniforms `worldPositionChange` and `simulationSpaceWorld` on the compute pipeline are removed; this only affects code that reached into the compute uniform object directly.
35+
- **Remove any `renderer.outputColorSpace = LinearSRGBColorSpace` override** that was added for this library. The three.js default (`SRGBColorSpace`) is now correct for both WebGL and WebGPU paths. Leaving the old override in place will double-darken the particles.
36+
- **`startColor`, `backgroundColor` values are now interpreted as sRGB.** `{ r: 1, g: 0, b: 0 }` renders as "CSS/Photoshop pure red," not as a raw linear 1.0. Configs authored under earlier versions will render slightly differently — to keep the old look exactly, run each color through `linearToSRGB` (exported from the package) once when loading a legacy config. Re-authoring in an updated editor against the new rendering is usually easier.
37+
- **User color map textures should be tagged `SRGBColorSpace`** (the three.js default when loading via `TextureLoader`). The library no longer overrides this. Non-color / data textures intended as masks should keep `NoColorSpace` as usual.
38+
739
## [2.4.0] - 2025-05-20
840

941
### Added

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,10 @@ enableWebGPU();
177177
import * as THREE from "three/webgpu";
178178
const renderer = new THREE.WebGPURenderer({ antialias: true });
179179
await renderer.init();
180+
// No special outputColorSpace handling needed — the library follows the
181+
// standard three.js linear workflow (user colors sRGB, shader math linear,
182+
// renderer converts on output). Leave outputColorSpace at its default
183+
// (SRGBColorSpace).
180184

181185
// 3. Create a GPU-accelerated particle system
182186
import { createParticleSystem, SimulationBackend } from "@newkrok/three-particles";
@@ -236,6 +240,22 @@ Automatically generated TypeDoc: [https://newkrok.github.io/three-particles/api/
236240

237241
## Important Notes
238242

243+
### Color Conventions
244+
245+
All RGB values in particle configs (`startColor`, `backgroundColor`) are
246+
**sRGB** — the same convention used everywhere else in three.js. Pass the
247+
value a color picker gives you (e.g. `{ r: 1, g: 0, b: 0 }` for pure red)
248+
and the renderer will display it correctly.
249+
250+
Internally the library decodes these to linear for shader math and relies
251+
on the renderer's standard output pass to convert back to sRGB on the way
252+
to the framebuffer. No special `outputColorSpace` setup is required; the
253+
three.js default (`SRGBColorSpace`) works.
254+
255+
User-supplied color map textures should also be tagged as sRGB
256+
(`texture.colorSpace = THREE.SRGBColorSpace`) — this is also the
257+
three.js default for color textures loaded via `TextureLoader`.
258+
239259
### Color Over Lifetime
240260

241261
The `colorOverLifetime` feature uses a **multiplier-based approach** (similar to Unity's particle system), where each RGB channel curve acts as a multiplier applied to the particle's `startColor`.

0 commit comments

Comments
 (0)