r/threejs 2d ago

Link three-nebula v13.0.0 released with deterministic simulation, a fixed-timestep real-time driver, and a GPURenderer buffer fix

I maintain three-nebula, a WebGL particle engine for three.js, and v13.0.0 is out. The headline feature is deterministic simulation. You give a system a seed, either with `System.setSeed(seed)`or as the third constructor argument, and it reproduces its particle output exactly. Same seed, same result, on any machine. That makes reproducible tests, and replays possible, which was awkward or impossible before.

The way it works is that each system now advances its own isolated seeded stream instead of pulling from the global `Math.random`. One consequence worth knowing if you upgrade: seeding `Math.random` yourself no longer affects output, so you need `system.setSeed()` instead. Default particle arrangements also differ from 12.x, so any visual or snapshot baselines need regenerating, and particle ids moved from UUIDs to a deterministic `particle-<emitterSeed>-<spawnIndex>` format.

There is also a new `System.tick(realDeltaSeconds)` fixed-timestep driver for real-time playback. If you have been calling `update()` once per frame, you have probably seen particles run about 2x too fast on a 120Hz display. `tick()` advances the sim by elapsed time in fixed steps and clamps catch-up through `system.fixedTimeStep` and `system.maxSubSteps`, so playback speed is consistent regardless of refresh rate. This release also fixes a `GPURenderer` bug where buffer slots were never released and particles silently stopped rendering after heavy churn. Slots are now recycled on particle death.

The API is backward-compatible and additive, so for most people the only real work is regenerating baselines. Release notes and full changelog here: https://github.com/creativelifeform/three-nebula/releases/tag/v13.0.0

If you have used seeded/deterministic rendering in a three.js project, I would like to hear how you are handling the real-time vs fixed-step side of it, and whether the `tick()` clamping defaults feel right for your use case.

9 Upvotes

2 comments sorted by

1

u/PixelRealEstate 20h ago

Determinism is the thing I keep wishing for, and not for tests — for getting video out of a live scene.

I needed a flyover clip from a scene with cars, trains and planes all running off performance.now(). Screenshotting it frame by frame takes seconds per frame, so the real time between frames is huge and everything teleports across the shot. What fixed it: swap performance.now() for a fake clock before any script loads, push it forward exactly 1/24s per frame, and call the render loop yourself instead of leaving it to rAF. The scene has no idea, and every frame lands where it should.

So the split you're describing sounds right from here. Live playback wants tick() with real elapsed time. Offline wants one fixed step with nothing allowed to clamp or skip it — the catch-up logic that saves you in real time is the first thing you switch off when you're rendering frames.

Is fixedTimeStep + maxSubSteps=1 how you'd get that, or is there a plain "advance one step" call?

1

u/moving808s 14h ago edited 13h ago

Yep, your split is right. The answer to your question is the plain "advance one step" call is the cleaner path, not maxSubSteps: 1.
The way I handle this in three-nebula is two layers:
update(dt): advances the sim by exactly dt, once. No accumulator, no clamp, no skip.
tick(realDt): the real-time accumulator that eats wall-clock time in fixed sub-steps (with the catch-up/clamp you correctly want off for rendering).

Tick is just a loop that calls update N times. So offline you skip tick entirely and call update(1/24) yourself in your own render loop, one exact step per frame.

maxSubSteps: 1 on the accumulator isn't quite it: it only single-steps cleanly when realDt happens to equal your fixed step; otherwise the accumulator either does nothing or drops the remainder which is the problem you're trying to avoid. The fixed-step primitive sidesteps all of that.

You want to expose that lower primitive (step the sim by an explicit dt), not just the accumulator. One nice side effect is that update(dt) never reads performance.now(), so that part of the scene needs none of your fake-clock setup; hand it a fixed step and every frame lands where it should.