acron.particles3d

Godot’s CPUParticles3D, replayed on Acron.

A Godot particle node describes its effect in plain numbers – how many particles, how big, how fast, which way, what colour over life – and those numbers are the part of an effect that PORTS. (The shaders on top of them do not; those are per-game.) This module is the machinery for replaying them, so a ported emitter reads as a transcription of its Godot node rather than as a guess.

Two kinds: Burst is one_shot – fired by an AnimationPlayer track or a line of script, lives out its lifetime, removes itself – and Loop re-emits for as long as it exists.

What is reproduced, per particle: emission shape, explosiveness (emission spread over the lifetime), direction + spread cone, initial and radial velocity, gravity, damping, scale_amount * scale_amount_curve, and color * color_ramp.

What is not: Godot’s Curve is a cubic with per-point tangents and this reads the same authored points as a polyline; hue_variation, angular_velocity, orbit_velocity and particle collision have no stand-in.

A particle is a ``NodePath``, integrated in Python on a render task, not a GPU buffer. That is the honest limit of this module and the reason a caller with a thousand-a-second emitter has to cut the count and say so. It is enough for effect-sized emitters (tens to low hundreds) and it is not a particle SYSTEM; replacing the inner loop with instanced geometry is the obvious next step and has not been taken, because it is a rendering change and this module’s whole test surface is headless.

FRAME NOTE. Every direction here is in ACRON’s frame. Where a number comes straight off a Godot node it is converted with the same change of basis the rest of the port uses – acron.gdformat.transform.TO_ZUP – and never by hand.

Burst(app, parent, *, geom, amount, lifetime)

One emitter's worth of particles, integrated on a render task.

Curve(*points)

A Godot Curve read as a polyline through its authored points.

Gradient(offsets, colors)

A Godot Gradient / GradientTexture colour ramp over normalised life.

Loop(app, parent, *, geom, amount, lifetime)

A CONTINUOUSLY emitting CPUParticles3D: the reactor core's plasma.

Global Functions

active()

Every live emitter, as a new set.

The registry itself is private because a caller must not be able to drop an emitter out of it without destroying it; this is the read side, for a scene teardown that wants to report what it swept and for tests.

card_geom(width, height, name='particle_card')

A camera-facing quad, in the frame set_billboard_point_eye expects.

clear_all()

Destroy every live emitter. For a scene teardown.

cone_geom(radius, height, segs=8)

The muzzle-flash cone: APEX at the origin, opening out along +Y.

Both the shape and the mapping are the authored mesh’s own, read out of muzzle_flash_particle_mesh_Cone.mesh: eight sides, the apex at the origin and a radius-1 ring one length out, and UVs that put the apex at the texture’s centre and the rim on a circle of radius 0.5 around it. That is what makes FlarePolar.png a flare – it is a disc of rays seen down the barrel, and any other unwrap smears it into a band.

emissive(rgb)

A Godot emissive colour, albedo * emission_energy, kept HDR.

This used to CLIP at 1.0, on the reasoning that the port draws these surfaces unlit “straight into the framebuffer” and the framebuffer would clip them anyway. It does not: the PBR frame renders into a float16 buffer and tonemaps it at the end of the frame, so an over-1 value is meaningful all the way through (measured on simplepbr’s Hable curve, 1.0 came out at 0.842 and 5.0 at 0.963) – so clipping first was throwing away both the extra brightness AND the hue: the laser’s flash is (1, 0.79, 0.79) * 5, which clipped to flat white where the curve gives a hot pink-white, which is what AgX gives it upstream.

It was also why bloom could not work. With every emitter pinned at 1.0 there is nothing above a glow threshold to find, and a bloom pass over this scene brightened the reactor core from yellow to gold and left it a hard-edged flat disc. Keeping the headroom is the half that had to come first (see engine/shaders/bloom.frag).

Only the negative clamp survives, because a negative emission is not a colour. Reaching the buffer with the value intact needs the surface to be drawn by a SHADER – set_color_scale on a shader-less node is clamped by the fixed pipeline before it multiplies anything, which is what unshaded() is now for.

glb_geom(loader, res_uri, node_name, name=None)

One AUTHORED mesh out of a GLB, rebuilt as a plain v3n3c4t2 geom.

Several of the demo’s effect meshes are Godot ArrayMesh resources (.mesh) that were extracted from a Blender file, and a .mesh is a binary Godot resource this port has no decoder for. It does not need one for the reactor core: the four meshes core.tscn names – CoreOutLight_Circle, _Sphere, _OuterRim, _Circle001 – are the four meshes of CoreOutLight.glb, which ships in the same directory and is what they were extracted FROM. Reading the GLB reads the authored geometry.

The mesh is copied through acron.geom.GeomBuilder rather than instanced where it lies, and the reason is the UV column. glTF names it texcoord.0, so a texture stage that does not say so leaves acron_MultiTexCoord0 reading a texcoord column that is not there and every fragment samples one texel (RUNTIME.md rule 14) – while every other geom in this module is a GeomBuilder product carrying the plain texcoord. Going through the builder makes the authored meshes and the hand-built ones the same kind of thing, and drops the GLB’s own material and cull state with it; unshaded() puts back what the .tscn asks for.

NOTE the frame. acron-gltf has already turned Y-up into Z-up IN THE VERTEX DATA – measured: every node in this GLB carries an identity transform – so a mesh authored flat in glTF’s XY plane arrives flat in Acron’s XZ plane facing -Y, which is the frame set_billboard_point_eye and card_geom() already work in.

godot_dir(x, y, z)

A Godot-frame direction as a Acron LVector3 (the port’s one conversion).

load_shader(name)

<shader dir>/<name>.vert + .frag, compiled once.

Returns None if it does not compile or no directory is set, which every caller has to be able to live with: a shader that fails takes only its own effect down.

ray_geom(width, length, name='particle_ray')

A quad running from the origin out to length along -Y (a light ray).

repeat_sampler()

A REPEATING sampler, for a map whose shader scrolls the UV off the tile.

Clamping is right for a particle quad – a repeat on a billboard bleeds the far edge back over the near one – and it is what texture() sets, so it is what every emitter before the reactor core got. The core’s two shaders are the exception: both declare repeat_enable and then walk the UV off the tile without bound (UV + speed * phase on the flare rings, UV + TIME * time_scale on the shell), and clamped they smear one row of texels across the whole surface.

It is a SAMPLER and not a second Texture because Acron’s texture pool hands out one Texture object per file: setting the wrap on it would set it for every other node drawing that map, last caller wins, silently. A SamplerState passed to set_texture overrides the wrap for one node and leaves the shared object alone.

set_shader_dir(path)

Where load_shader() looks for <name>.vert / <name>.frag.

The shaders a particle effect is drawn with belong to the GAME – they are ported .gdshader files – so the package holds the machinery and the game says where its own source lives. Clears the compiled cache, so a second game in one process cannot inherit the first’s shaders.

sphere_geom(radius, height=None, segs=12, rings=8)

Godot’s SphereMesh: radius across, height along its own +Y.

A SphereMesh with height != 2 * radius is a squashed/stretched sphere, which is how the bullet trail’s radius 0.05, height 0.3 capsule and the ember’s radius 0.01, height 0.1 are authored.

texture(loader, res_uri)

Load one of the demo’s VFX PNGs (all RGBA), clamped and mipmapped.

unshaded(np, additive=False, two_sided=True, depth_write=False, bin_order=40, shader=None)
unshaded_shader()

The unlit HDR surface shader, compiled once (None if it fails).

white_texture()

A 1x1 white texture, so the unlit shader’s sampler is always bound.

An unbound sampler happens to read white on this engine; depending on that is the trap (Acron binds every uniform a shader DECLARES).