Implement the GameMaker particle system - #378
Open
Ananim353 wants to merge 6 commits into
Open
Conversation
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.
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).
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.
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 subscribe to this conversation on GitHub.
Already have an account?
Sign in.
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.
part_*was not implemented, so any game that uses particles drew nothing wherean 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_petaland the whirlingpt_petal_whirl, both with gravity at an angle ontop of speed wiggle.
What this adds
src/particles.{c,h}with the three GameMaker resources — systems own emittersand live particles, types are global so any system can stream any type — plus
the builtins for all three. Both pools live on
Runnerand reuse destroyed ids,the same tombstone convention the
ds_*pools already use, because games dependon it.
Systems with automatic drawing join the depth-sorted drawable list as a new
DRAWABLE_PARTICLE_SYSTEMentry, sopart_system_depthplaces them amonginstances, 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 drawpass.
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 theglobal 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
--seedandrandomize()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 4passes
part_type_direction(-45, -90), which sweeps downward; swapping thebounds would flip the spray.
Not implemented
part_type_shape— it needs the shape textures baked into GameMaker's ownruntime, 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-starsat frame9000, and the
deltarune-chapter4jackenstein path test at frames 9473 and9474, all still match their expected images byte for byte.
Every
part_*call in DELTARUNE chapters 4 and 5 and in Pizza Tower nowresolves, checked with
--print-unknown-functions;vertex_*still reports asunknown 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_drawitorpart_system_update, relying entirely onautomatic update and automatic draw through the depth list, and it uses
part_particles_clearandpart_emitter_destroy, which DELTARUNE nevertouches. 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 warningsattributable 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.