Skip to content

test: add world-space simulation invariant tests#35

Merged
NewKrok merged 12 commits into
masterfrom
claude/fix-world-simulation-CfOH4
Apr 25, 2026
Merged

test: add world-space simulation invariant tests#35
NewKrok merged 12 commits into
masterfrom
claude/fix-world-simulation-CfOH4

Conversation

@NewKrok
Copy link
Copy Markdown
Owner

@NewKrok NewKrok commented Apr 21, 2026

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

claude added 5 commits April 21, 2026 12:59
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>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 21, 2026

Bundle Size Report

File Size
three-particles.min.js 79 KB (81385 bytes)

✅ Bundle size is within the 150 KB limit.

NewKrok and others added 7 commits April 21, 2026 20:52
…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>
@NewKrok NewKrok merged commit 4eb2510 into master Apr 25, 2026
7 checks passed
@NewKrok NewKrok deleted the claude/fix-world-simulation-CfOH4 branch April 25, 2026 22:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants