Skip to content

Implement the GameMaker particle system - #378

Open
Ananim353 wants to merge 6 commits into
ButterscotchRunner:mainfrom
Ananim353:particles
Open

Implement the GameMaker particle system#378
Ananim353 wants to merge 6 commits into
ButterscotchRunner:mainfrom
Ananim353:particles

Conversation

@Ananim353

Copy link
Copy Markdown

part_* was not implemented, so any game that uses particles drew nothing where
an effect belonged, with no diagnostic of any kind. DELTARUNE chapters 4 and 5
lose their falling leaves, drifting dust, confetti and cherry blossom petals to
this; Pizza Tower loses its effects the same way.

petals.mp4

Chapter 5's garden, running on this branch. Two systems at once: the ambient
pt_petal and the whirling pt_petal_whirl, both with gravity at an angle on
top of speed wiggle.

What this adds

src/particles.{c,h} with the three GameMaker resources — systems own emitters
and live particles, types are global so any system can stream any type — plus
the builtins for all three. Both pools live on Runner and reuse destroyed ids,
the same tombstone convention the ds_* pools already use, because games depend
on it.

Systems with automatic drawing join the depth-sorted drawable list as a new
DRAWABLE_PARTICLE_SYSTEM entry, so part_system_depth places them among
instances, tiles and layers rather than needing a separate pass. Automatic
updates run once at the end of Runner_step, after End Step and before the draw
pass.

Three commits, separable: the core, the rest of the API, and the fixes from a
review pass over the first two.

Choices worth flagging

Particles draw from a private random stream, not rand(). Sharing the
global stream would mean every particle spawn shifts the sequence the game
itself sees — so merely adding an effect to a scene would perturb unrelated
randomised behaviour, and every seeded screenshot test with it. The cost is that
--seed and randomize() do not reach particles.

A per-system cap of 8192 particles, warned about once when hit. GameMaker
has no equivalent, but an emitter left streaming in a room the player never
leaves grows without bound, which the console targets cannot absorb.

Ranges are stored exactly as the game passes them, without normalising min
against max. GameMaker evaluates min + random * (max - min), and Chapter 4
passes part_type_direction(-45, -90), which sweeps downward; swapping the
bounds would flip the spray.

Not implemented

part_type_shape — it needs the shape textures baked into GameMaker's own
runtime, which cannot be reproduced here. Gaussian and inverse-gaussian emitter
distributions fall back to linear. The GMS 2.3 emitter delay/interval calls, and
part_type_colour_mix/rgb/hsv, are absent.

Two known behaviours, both shared with existing drawables rather than introduced
here: two systems at the same depth are ordered by pool slot, which id reuse
makes history-dependent; and changing a system's depth from inside a Draw event
can draw it twice that frame, the same way changing an instance's depth
mid-draw already can.

Verification

The existing screenshot tests are unaffected — loritta-and-the-stars at frame
9000, and the deltarune-chapter4 jackenstein path test at frames 9473 and
9474, all still match their expected images byte for byte.

Every part_* call in DELTARUNE chapters 4 and 5 and in Pizza Tower now
resolves, checked with --print-unknown-functions; vertex_* still reports as
unknown in the same output, so the check is measuring something.

Pizza Tower is worth calling out because it exercises what DELTARUNE does not:
it never calls part_system_drawit or part_system_update, relying entirely on
automatic update and automatic draw through the depth list, and it uses
part_particles_clear and part_emitter_destroy, which DELTARUNE never
touches. Between the two games both spawn paths and both draw paths are covered.

Cross-compiles clean for 32-bit MIPS with -Wall -Wextra; the only warnings
attributable to the new file are stb_ds macro expansions that existing files
produce identically.

One caveat on CI

A pull request from a fork skips the commercial-game downloads, so the two
screenshot tests above will not run here. They pass locally.


Nothing under part_* existed, so any game that used particles simply drew
nothing where an effect belonged, with no diagnostic. DELTARUNE Chapters 4
and 5 lose their falling leaves, drifting dust, confetti and cherry blossom
petals to this; the calls all resolved to "unknown function".

Adds src/particles.{c,h} with the three GameMaker resources -- systems own
emitters and live particles, types are global and describe appearance and
motion -- plus 23 builtins covering system, type and emitter management.
Both pools hang off Runner and reuse destroyed ids, the same tombstone
convention the ds_* pools already use, because games depend on it.

Systems with automatic drawing enter the depth-sorted drawable list as a new
DRAWABLE_PARTICLE_SYSTEM entry, so part_system_depth places particles among
instances and tiles instead of needing a separate pass. Automatic updates run
once at the end of Runner_step, after End Step and before the draw pass.

Two deliberate choices worth calling out:

* Particles draw from their own random stream rather than rand(). Sharing it
  would make every particle spawn shift the sequence the game itself sees, so
  adding an effect to a scene would perturb unrelated randomised behaviour and
  every seeded screenshot test with it. The cost is that --seed and randomize()
  do not reach particles.

* PARTICLE_SYSTEM_MAX_PARTICLES caps a system at 8192 live particles, warning
  once when it is hit. GameMaker has no such limit, but an emitter left
  streaming in a room the player never leaves grows without bound, which the
  console targets cannot absorb.

Ranges are stored exactly as the game passes them, without normalising min
against max: GameMaker evaluates "min + random * (max - min)", and Chapter 4
passes part_type_direction(-45, -90), which sweeps downward. Swapping the
bounds would flip the spray.

Verified on Linux/SDL2 against DELTARUNE Chapter 4 and 5 data: every part_*
call now resolves (checked with --print-unknown-functions; vertex_* still
reports as unknown, so the check is measuring something). The existing
screenshot tests are unaffected -- loritta-and-the-stars at frame 9000 and
the deltarune-chapter4 jackenstein path test at frames 9473 and 9474 all
still match their expected images byte for byte.
The previous commit implemented what DELTARUNE actually calls. This adds the
functions that fall out of the same machinery, so the system reads as a
particle implementation rather than one game's subset.

Colour gets the three-stop curve alpha already had, interpolated per byte,
which is correct whatever order the channels sit in because GML colours reach
the renderer unrepacked. colour1 and colour2 collapse the stops, the same way
alpha1 and alpha2 do; a two-stop curve puts its middle on the straight line
between the ends so it stays linear.

part_type_orientation adds a drawn angle, optionally measured from the
direction of travel so a sprite drawn nose-first keeps pointing along its arc
as gravity bends it. part_type_step reuses the deferred-spawn list that
part_type_death already needed, since spawning inside the movement loop can
realloc the array out from under the iteration.

Also: part_system_position, part_system_automatic_update, part_system_clear,
part_particles_create and its _colour form, part_particles_count/clear, and
exists/clear for all three resources. Both the colour and color spellings are
registered, matching how draw_ellipse_colour is handled.

Kept out deliberately: part_type_shape, which needs the shape textures baked
into GameMaker's runtime and so cannot be reproduced exactly; and the GMS2.3
emitter delay/interval calls, which model something the classic stream rate
does not express.

The existing screenshot tests are still byte-identical (loritta at 9000,
deltarune-chapter4 jackenstein at 9473 and 9474), and Chapter 5's cherry
blossom system still renders after the drawing path grew colour, rotation and
a system origin.
A review pass over the two particle commits turned up one real bug and a
handful of sharp edges. All of them are in particles.c.

The bug: after drawing an additive type, the system forced the blend mode
back to bm_normal instead of restoring what the caller had. Blend state in
GML is global and sticky, so a game that wraps its scene in
gpu_set_blendmode_ext to darken it lost the effect from the particle system
onward -- the player would see the scene split, darkened above the system's
depth and full-brightness below, and only in scenes that happen to contain an
additive effect. The comment claiming the current mode could not be read back
was simply wrong: gpuGetBlendMode and gpuGetBlendFactors are both in the
vtable and implemented on gl, gl_legacy and ps2. State is now saved on the
first change and restored properly, going through the factors when the saved
mode reads back as bm_complex, since that is what an _ext call leaves behind.
Both getters are optional in the vtable, so a backend without them still gets
the old bm_normal fallback.

The spawn count from GML was trusted as a loop bound even after the system
filled up, so part_emitter_stream(ps, em, ty, 2000000000) on a full system
span a two-billion-iteration no-op every frame and hung the game with no
diagnostic. particleSpawnAt now reports the cap and its three callers stop.

part_type_life picked from a half-open range, so part_type_life(t, 1, 3) never
returned 3. Integer ranges are now inclusive at both ends, as GameMaker reads
them.

Ellipse and diamond emitter regions were sampled by rejection with a bounded
attempt count, and on exhaustion accepted whatever point it was holding --
which for a diamond, at half the bounding box, leaked a visible particle into
a corner roughly once in five hundred. Both shapes are now sampled directly,
which cannot escape the region and is cheaper besides.

Also: negating the count in the "negative means a chance" convention was
signed overflow at INT32_MIN; the private random stream is reset by
Particles_freeAll so a restarted game replays the same particles; and
part_system_automatic_draw(ps, false) no longer forces a structural rebuild,
since the draw pass already filters on that flag the way it filters instance
visibility -- the common automatic_draw(false)/drawit() idiom was dragging a
full rebuild and re-sort of every drawable in the room behind it each frame.

Screenshot tests unchanged (loritta at 9000, deltarune-chapter4 jackenstein at
9473 and 9474), and Chapter 5's petals still render after the sampling and
lifetime changes.
@ButterscotchRunner ButterscotchRunner locked and limited conversation to collaborators Aug 4, 2026
YoYo Games publish the HTML5 runtime (github.com/YoYoGames/GameMaker-HTML5,
Apache 2.0), and its scripts/yyParticle.js is the closest thing to a
specification this API has. Reading it against this implementation turned up
four places where the two disagree.

Wiggle. GameMaker runs four independent triangle waves off the particle's age:
direction on a 24-step period, speed on 20, orientation on 16 and size on 16,
each phase-shifted by its own multiple of a random seed drawn per particle.
This ran one shared wave on a 32-step period, so a particle's four properties
oscillated in lockstep and a spray pulsed instead of shimmering. The particle
now carries that seed and each property gets its own wave.

Update order. A system is walked in three passes -- age, then movement, then
size -- and the emitters spawn only after all three. Two consequences follow
that this did not have: a streamed particle does not move on the step it is
born, and a step's speed and direction increments land before the particle
travels rather than after it, so its very first step already runs at
speed + speed increment. Death and step particles still move on their spawn
step, matching upstream, because they join the array between the ageing and
movement passes rather than at the end of the frame.

Step versus death particles. A type emits its death particles instead of its
step particles on the step it dies, not as well as them. A type configured with
both used to emit a double helping on its last step.

Fractional counts. part_emitter_stream and part_emitter_burst keep the count as
a real and spend the fractional part as a chance of one more particle, which is
how a game asks for fewer than one particle per step. Both truncated to int, so
an emitter asked for 0.5 particles per step emitted nothing at all, ever.

One divergence is deliberate, and now carries its evidence in the comment above
particleRandomRange. MyRandom() returns the low bound whenever the range is not
positive, so on HTML5 a reversed pair collapses to a constant. Disassembling all
five DELTARUNE chapters finds three reversed calls, all in Chapter 5:
part_type_direction with (-1, -165), (-45, -90) and (-40, -90). Each is plainly
meant to be a fan, and clamping turns three sprays into single-direction jets,
so the Windows runtime these games are built against evidently sweeps them.
Chapter 4, which the previous comment credited, has no reversed ranges at all.

Verified: loritta at frame 9000 byte-identical; Chapter 5's petals still render
and move, and two cold runs of the same recorded session produce byte-identical
frames, so the private random stream keeps particles reproducible without
touching the sequence the game itself sees.
The previous commit let a reversed pair sweep downward and argued the runtime
must behave that way, on the grounds that clamping would turn DELTARUNE's leaf
and confetti sprays into straight lines. Trying it in the particle editor
settles it the other way round: with gravity, direction increment and wiggle all
zeroed, part_type_direction(-45, -90) emits a single line along -45 and no fan
at all. GameMaker clamps a non-positive range to its low bound, exactly as
MyRandom() does in the HTML5 runtime, so particleRandomRange and
particleRandomIntRange now do the same.

A straight line turns out to be the right look for both effects, which is
presumably why it never looked broken: obj_festival_particles and
obj_part_leaves are steady diagonal drifts -- confetti and wind-blown leaves --
and neither applies gravity, so there was never an arc for a fan to fill.

That previous commit also named three affected call sites. There are two. The
third, obj_part_leaves_2, was a misreading of the bytecode: its particle type
lives in an array, and an array-accessor push pops the index and the instance
type before pushing the element, so treating those two operands as call
arguments shifted the whole argument list. Read properly it asks for
(-165, -135), which is not reversed.

Verified: loritta at frame 9000 byte-identical, Chapter 5's petals unchanged
(obj_sakurafubuki_new uses a constant direction and never took this path).
@Un1q32

Un1q32 commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Can you restructure this so instead of having particles.c and wrappers in vm_builtins.c, everything is just in vm_builtins.c

particles.c and its header are gone. The resources live in runner.h next to
the ds_* ones, and every part_* builtin now does its own work instead of
calling a one-line wrapper. Only four entry points stay external, for the
depth list, the frame step and teardown.

Particles also outlive the type they came from, the way the runtime does.
A destroyed type answers part_type_exists with false and can no longer be
spawned from, but it keeps its pool slot until its last particle dies: those
particles age out looking as they did instead of vanishing on the next step,
and the id is never handed to a new type under them. Checked against the
GameMaker runtime running the same bytecode: it keeps such particles alive,
lets them die on schedule, and draws them in their own type's colour.
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants