test: add world-space simulation invariant tests#35
Merged
Conversation
Adds a dedicated invariant test suite for SimulationSpace.WORLD that encodes the semantic contract of world-space particles independently of the current Gyroscope-based implementation. The same tests will validate the upcoming refactor that removes the wrapper and stores particles in world coordinates directly. Layer 1 — WORLD invariants (9 tests): - Emitted particles stay at their world position when the emitter translates, rotates, or both, including over 1000 frames - Variable frame dt does not cause jitter - Quaternion q and -q are treated identically (no hidden recalc) - Gravity is world-down regardless of emitter rotation / translation (marked .failing — current impl transforms gravity through the emitter-local frame and uses the emitter position as a direction vector, causing particles to drift sideways / upward) - instance.position offsets the spawn origin under the parent (Option 2 semantics) without dragging existing particles - Subsequent bursts originate at the current emitter pose Layer 2 — LOCAL regression (2 tests): - Particles visually follow parent translation and rotation Layer 3 — integration (2 tests): - Death sub-emitter spawns at the parent particle's world death location, not at the current emitter pose - Directional force field stays world-aligned when the emitter rotates (marked .failing — Gyroscope's rotation-cancellation is not invoked by getWorldQuaternion, so _inverseQuat ends up pre-rotating the field direction by the parent rotation) The three .failing markers document existing bugs that the WORLD-space refactor will fix; they will be converted back to regular it() once the refactor lands. Co-Authored-By: Claude <noreply@anthropic.com>
…pace The WORLD simulation space is now implemented by holding the particle system's matrixWorld at identity and writing world-space coordinates directly into the particle buffer. This replaces the previous scheme that wrapped the system in a three/examples Gyroscope, ate the parent rotation, and compensated buffer positions each frame by subtracting the emitter's world movement. Why: - Removes the three/examples Gyroscope dependency (not a stable API). - Fixes a gravity direction bug: gravity is now applied as a constant world vector instead of being derived from the emitter's world position treated as a direction — particles no longer drift sideways or upward when the emitter moves or rotates. - Fixes a force field direction bug: the previous code pre-rotated world-space field directions by the emitter's world quaternion, but because Gyroscope's rotation cancellation is only applied inside updateMatrixWorld (not updateWorldMatrix), the queried quaternion included the parent rotation — so rotating the emitter rotated the field. With world-space buffers no transform is needed. - Eliminates the frame-lag jitter caused by subtracting the previous frame's world position change. Implementation: - createParticleSystem: in WORLD mode, set matrixWorldAutoUpdate = false and hold matrixWorld at identity. - Each frame, compute generalData.sourceWorldMatrix = parent.matrixWorld × particleSystem.matrix and extract translation + rotation for the emission shape and new particle placement. - Emission writes buffer positions in world coordinates directly (rotated shape offset + sourceWorldMatrix translation). - Remove worldPositionChange subtraction from the CPU integration and the CPU-side shadow simulation for GPU compute. - Force fields / collision planes: copy world-space config through unchanged in WORLD mode; keep the local-space transform in LOCAL mode. - Sub-emitters: convert the death/birth world position to the parent object's local frame before passing it as transform.position so the sub-emitter ends up at the particle's world location regardless of where the parent emitter is now. Breaking API changes: - ParticleSystem.instance is now always THREE.Points | THREE.Mesh; the union with Gyroscope is removed. Code that checked `instance instanceof Gyroscope` or reached into `instance.children[0]` to find the Points object must use `instance` directly. - ParticleSystemInstance.wrapper is removed. - In WORLD mode, instance.matrixWorld is identity regardless of parent transforms. The emitter's world pose is still read from the parent chain for emission source positioning; instance.position and instance.rotation offset the spawn origin under the parent (Option 2 semantics, matching Unity's world simulation space). The three .failing markers added in the previous commit are removed; those tests now pass on the refactored path. Co-Authored-By: Claude <noreply@anthropic.com>
…mpute The WebGPU compute path mirrored the CPU's world-position-change compensation via uSimSpaceWorld + uWorldPositionChange uniforms. With the previous commit the CPU no longer needs that compensation (the particle buffer stores world coordinates directly in WORLD mode), and the GPU should not either. Remove both uniforms and the conditional `pos -= worldPositionChange` block from the two compute kernels. GPU emission now adds the emitter's world translation from generalData.sourceWorldMatrix to the initial position written into the storage buffer — symmetric with the CPU emit path. Also updated: - ComputeUniforms / ModifierUniforms types - updateComputeUniforms signature (no more worldPositionChange / isWorldSpace) - Tests that asserted on the removed uniforms Co-Authored-By: Claude <noreply@anthropic.com>
- architecture.md: rewrite the "World vs Local Simulation Space" section to describe the new world-coordinate buffer + identity-matrixWorld model, drop references to Gyroscope and worldPositionChange. - llms.txt / llms-full.txt: update the `instance` type to THREE.Points | THREE.Mesh (no Gyroscope); add a short description of LOCAL vs WORLD semantics under the SimulationSpace enum. - CHANGELOG.md: add an Unreleased section documenting the breaking WORLD-space rewrite, the fixed gravity / force field / sub-emitter bugs, and the migration steps (instance type, matrixWorld semantics, removed ParticleSystemInstance.wrapper, dropped Gyroscope dependency). Co-Authored-By: Claude <noreply@anthropic.com>
…b-emitter, GPU Adds five regression tests covering subsystem interactions with the SimulationSpace.WORLD rewrite: - Collision plane in WORLD mode with a horizontally moving emitter — verifies that the plane-particle intersection check runs in world coordinates and kills on schedule (not smeared by emitter motion). - POINT (attractor) force field in WORLD mode with a translating emitter — verifies the attractor position stays world-anchored and does not drag along with the emitter (analogous to the directional force field test already in place). - SubEmitterTrigger.BIRTH in WORLD mode — verifies the BIRTH-spawned child system is decoupled from subsequent motion of the main emitter (complements the existing DEATH-event test). - `instance.rotation` Option-2 semantics — verifies that rotating the system instance under its parent rotates the shape emission axis, mirroring the existing `instance.position` Option-2 test. - GPU compute WORLD mode — verifies the instance is the raw renderable (no wrapper Object3D) and that its matrixWorld stays at identity even after the parent is translated and rotated between ticks. Statement coverage 80.4 -> 81.3%, branch coverage 86.3 -> 87.6%. Co-Authored-By: Claude <noreply@anthropic.com>
Contributor
Bundle Size Report
✅ Bundle size is within the 150 KB limit. |
…pensation Two user-visible rendering bugs in the WebGPU TSL material path are fixed together because both stem from the same color/discard pipeline in tsl-shared.ts. 1. discardBackgroundColor silently stopped working on POINTS, INSTANCED and MESH renderers (Shield, Fireworks, Magnetic Field, Implosion, Explosion with Smoke, …). The helper `applyBackgroundDiscard` was wrapped in a TSL `Fn`, which placed `Discard()` inside a callable shader function. WGSL `discard;` is a fragment-main statement and does not propagate out of a wrapped `Fn`, so the discard silently no-op'd. The helper is now a plain TypeScript function that emits `Discard()` inline into the caller's fragment `Fn` — matching the trail material, which was unaffected and confirms the working pattern. 2. `compensateOutputSRGB` is removed from all TSL materials. It was added under the assumption that `renderer.outputColorSpace = SRGBColorSpace` (so the renderer runs a linear→sRGB output pass that needs cancelling), but the GLSL/WebGL path writes raw sRGB directly to the framebuffer and requires `LinearSRGBColorSpace`. The examples already set that, so the compensation was running with no output pass to cancel — sRGB values were getting sRGB→linear encoded, darkening partially-transparent edges (visible as a faint black rim on additive-blended particles). Both paths now behave identically. Migration: WebGPU users must set `renderer.outputColorSpace = THREE.LinearSRGBColorSpace` after `renderer.init()`. README, llms.txt and llms-full.txt setup snippets are updated; CHANGELOG [Unreleased] documents the change alongside the existing WORLD-space breaking changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Leftover from the pre-89c6123 update loop diagram. The WORLD-space rewrite stores positions in world coordinates directly and no longer subtracts an emitter-motion delta per frame, so the compensation callout in the update loop ASCII tree no longer describes what the code does. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… spawns Four bugs surfaced by the code review of the WORLD-space refactor, all rooted in the same Unity-parity question: how should parent scale and emitter motion interact with the simulation frame? - LOCAL gravity now divides by the emitter's world scale so the rendered fall rate stays world m/s² regardless of parent scale (matches Unity's world-constant Physics.gravity). - WORLD shape-emission spawn offsets are now scaled by the emitter's world scale (matches Unity's Shape module with Scaling Mode = Local/Hierarchy). Live particles remain unaffected by subsequent scale changes. - LOCAL-mode sub-emitter death/birth callbacks call updateMatrixWorld() before localToWorld() so the spawn position reflects any parent move that happened after the last scene traversal. - The positionNeedsUpdate CPU→GPU upload guard no longer flags stationary particles for re-upload when the emitter moves — the worldPositionChange subtraction was already removed, so those conditions were dead and were causing full-buffer re-uploads every frame. Minor cleanup: drops the now-dead _tmpVec3B temp and lastWorldQuaternion state on GeneralData, replaces stale Gyroscope-era comments in the invariant test suite, and adds a worldScale field to GeneralData so both simulation paths decompose the emitter scale into a reusable Vector3. Adds five regression tests in three-particles-world-space-invariants.test covering: LOCAL gravity under scaled parent, WORLD spawn offset under scaled parent, live particles immune to post-spawn scale change, sub-emitter spawn at current parent world position after stale scene traversal, and positionAttr.version stability with stationary particles. Total: 1140/1140 tests green (1135 previous + 5 new), lint clean, build green, example webpack bundle regenerated. Co-Authored-By: Claude <noreply@anthropic.com>
…se storage-buffer uploads
Resolves the flame-barrier WORLD+GPU visual bug where all particles snapped
to y=0 on every frame. The WebGPU compute dispatch rounds up to whole
workgroups, so threads with `instanceIndex >= maxParticles` ran past the
per-particle init region and wrote zero into the first collision plane's
`position.y` (aliased onto `initBase+3` for i == maxParticles). A top-level
`If(i < maxParticles)` guard fixes this. Added regression test covering
the layout invariance and the guard placement.
Color pipeline standardised to the three.js sRGB workflow: user colors are
interpreted as sRGB, decoded to linear for shading, and converted back on
output. Removes the previous `LinearSRGBColorSpace` override requirement
that broke every other material in the same scene. New `linearToSRGB` /
`sRGBToLinear` helpers exported from `color-utils`. GLSL shaders include
`<colorspace_fragment>`; TSL materials no longer force `NoColorSpace`.
Storage-buffer uploads now use `addUpdateRange()` for the per-particle
init slots, force field, and collision plane regions so partial uploads
don't stomp GPU-side compute writes. `deactivateParticleInModifierBuffers`
proactively zeros `orbitalIsActive.w` and `color.a` on the CPU mirror so a
recycled slot can't briefly re-render with stale state before the GPU
death-check catches up.
`updateConfig({ simulationSpace })` now deactivates live particles and
flips `matrixWorldAutoUpdate` to match what `createParticleSystem` would
have set, so a LOCAL↔WORLD toggle doesn't leave buffer positions in the
old frame.
Dead-particle vertex early-out now emits `vec4(0,0,0,-1)` (clipped behind
the camera) instead of `vec4(0)`, avoiding a NaN after perspective divide
that some drivers rasterised at the scene origin.
BREAKING CHANGE: user color inputs (`startColor`, `backgroundColor`) are
now interpreted as sRGB. Legacy configs can be migrated through
`linearToSRGB` once on load. Consumers must drop any
`renderer.outputColorSpace = LinearSRGBColorSpace` override.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…S__ global Mirrors the three.js pattern (`REVISION` constant + `window.__THREE__` global) so consumers can introspect the loaded version at runtime for debugging, bug reports, or compatibility checks. The version string is injected at build time by tsup's `define` option from `package.json`, so the bundle never drifts from the package manifest. A `0.0.0-dev` fallback keeps jest imports working — ts-jest doesn't apply the build-time define. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bounds-check `If(i < maxParticles)` guard added in the previous commit nested the whole kernel body one level deeper, which bumped the prettier-expected indent by 2 spaces on every affected line. CI lint flagged 326 formatting errors; `eslint --fix` now brings them back in line with the repo's prettier config. Also dropped an obsolete `eslint-disable-next-line` directive in `webgpu.ts` whose target rule no longer fires on that line. No functional change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The combined modifier-and-forces integration test spawned 5 particles from the default SPHERE shape (random offsets in [-1, 1] along Y) and asserted that at least one ended up at y < -0.01 after 200ms of simulation. With the Unity-parity gravity convention (negative Y component pulls _up_, not down), the only way that assertion could ever pass was if the random spawn produced a particle low enough that 200ms of upward gravity didn't lift it above -0.01 — a seed-dependent outcome that flaked on CI. The test now captures the spawn positions on frame 1 and asserts that Y has _moved_ from its spawn point on subsequent frames, independent of the sign. That preserves the original intent (gravity drives vertical motion) without encoding an assumption about direction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a dedicated invariant test suite for SimulationSpace.WORLD that
encodes the semantic contract of world-space particles independently of
the current Gyroscope-based implementation. The same tests will validate
the upcoming refactor that removes the wrapper and stores particles in
world coordinates directly.
Layer 1 — WORLD invariants (9 tests):
translates, rotates, or both, including over 1000 frames
(marked .failing — current impl transforms gravity through the
emitter-local frame and uses the emitter position as a direction
vector, causing particles to drift sideways / upward)
semantics) without dragging existing particles
Layer 2 — LOCAL regression (2 tests):
Layer 3 — integration (2 tests):
location, not at the current emitter pose
(marked .failing — Gyroscope's rotation-cancellation is not invoked
by getWorldQuaternion, so _inverseQuat ends up pre-rotating the
field direction by the parent rotation)
The three .failing markers document existing bugs that the WORLD-space
refactor will fix; they will be converted back to regular it() once the
refactor lands.
Co-Authored-By: Claude noreply@anthropic.com