Repository navigation
GPU Particles
Regular Custom Particle Types are simulated one by one on the CPU every tick and their quads rebuilt every frame. Fine for a few hundred, but packs that use them for weather and ambience (rain, snow, sandstorms, ash, spores, embers) spawn tens of thousands and the game crawls.
A GPU Particle is spawned exactly like any other particle (biome ambient particles, block and particle emitters, /particle, mods calling addParticle) but it never becomes a particle object. The spawn writes one small record (position, velocity, spawn tick, lifetime, seed, light) into a buffer on the graphics card, and from then on the vertex shader computes where it is, how big, what colour and which frame from that record and the current time. No ticking, no collision, no per-particle work at all: 100.000 of them are one draw call.
Good for: rain, snow, dust, ash, spores, embers, sparks, bubbles, anything numerous and dumb. Not good for: particles that must collide, stop, react to blocks, emit sounds or spawn other particles. Use Custom Particles for that.
A GPU particle is a Custom Particle Type with "type": "gpu": same folder (assets/[namespace]/polytone/custom_particles/[name].json), same id you spawn from anywhere, same overriding of vanilla ids. The format mirrors Custom Particle Types where the concept exists (render_type, rotation_mode, light_level, limit, initializer, random_sprite), minus everything that would need per-tick CPU work.
{
"type": "gpu",
"texture": "minecraft:textures/particle/generic_7.png",
"render_type": "translucent",
"initializer": {
"size": "0.05 + r.rand() * 0.04",
"lifetime": "100 + r.rand() * 60",
"roll": "r.rand() * 6.283"
},
"gravity": 0.006,
"friction": 0.92,
"sway": 0.4,
"fade": [0.1, 0.2]
}Then spawn it, for example as a biome ambient particle (polytone/biome_modifiers/plains.json):
{
"particle": {
"options": { "type": "my_pack:snow" },
"probability": 0.6
}
}or from a block, particle or entity emitter with the usual x/y/z/dx/dy/dz expressions. dx dy dz are the initial velocity in blocks per tick, like vanilla, and the emitter's red/green/blue/alpha/roll/size/custom overrides work like they do for custom particles.
| Field | Description | Default |
|---|---|---|
type |
Must be gpu. |
required |
texture |
Full texture path (namespace:textures/...png). A vertical strip of frames frames if animated. |
required |
limit |
Ring buffer capacity. When more particles are alive than this, the oldest are overwritten. | 16384 |
force_spawn |
Ignore the 32 block player proximity check when spawning, like custom particles. Needed for anything spawned far from the camera. | false |
render_type |
Same values as custom particles: opaque, cutout, translucent, additive_translucent, invisible... Anything but the two translucent ones renders as alpha cutout with depth write. |
opaque |
rotation_mode |
Same values as custom particles, but only four orientations exist on the GPU: look_at_xyz (faces the camera; the default and what everything else falls back to), look_at_y (turns around Y only), look_up (lies flat), movement_aligned (stands along its current velocity, for streaks and rain). |
look_at_xyz |
light_level |
Minimum block light, 0-15, like custom particles. Light is sampled once where the particle spawns. | 0 |
initializer |
Spawn-time expressions, see below. | |
gravity |
Downward acceleration in blocks per tick squared. Vanilla particles use about 0.04, snow 0.005. Negative rises. |
0 |
friction |
Velocity kept per tick, 0-1. 1 = none, vanilla particles use 0.98. With gravity this gives a terminal velocity like the real thing. |
1 |
size_end |
Half-width at death; the size interpolates from the initializer's size to this. |
none |
color_end |
Colour at death, #AARRGGBB; interpolated from the initializer's colour. |
none |
fade |
[in, out] fractions of the lifetime spent fading alpha in and out. |
[0, 0] |
sway |
Amplitude of a per-particle horizontal wobble, in blocks. | 0 |
spin |
Roll speed in radians per tick, added to the initializer's roll. |
0 |
aspect |
Height divided by width, for streaks. | 1 |
frames |
Frames in the texture strip. Played over the lifetime, or one picked at random with random_sprite. |
1 |
random_sprite |
Pick one random frame instead of animating, like custom particles. | false |
area |
{"size": [x, y, z], "count": n}: one spawn becomes n quads spread at random over a size box around the spawn point. Same lifetime, velocity and colour, own sway phase and roll. This is how you get a downpour out of a few hundred spawns per tick instead of tens of thousands: it costs nothing extra on the CPU. Max 1024. |
none |
kill_below_heightmap |
Hide quads that are under the world surface (motion-blocking heightmap of a 128x128 area around the camera, refreshed every half second): rain dies at the ground and never shows in caves, snow spawned under a tree roof is not drawn. | false |
uniforms |
Map of extra float uniforms for a custom shader, each a Scripting Expression, re-evaluated every frame. | none |
shader |
Id of a custom core shader, see below. | polytone:gpu_particle |
Same idea and names as the custom particle initializer: Block Expressions (scripting only, no legacy exp4j syntax) evaluated once per particle when it spawns (so r.rand(), position, biome, block state, time... are all fair game), then frozen into the particle's record. Constants are fine too. Units are vanilla's: ticks, blocks per tick, size is the quad half-width like quadSize.
| Parameter | Description | Default |
|---|---|---|
size |
Half-width in blocks. | 0.1 |
lifetime |
Ticks. | 40 |
red green blue alpha
|
Colour channels, 0-1. | 1 |
colormap |
A Colormap sampled where the particle spawns and multiplied into red/green/blue. Same field as custom particles, except a GPU particle never ticks, so the colour is frozen at spawn like their on_spawn cache mode. |
none |
roll |
Starting roll in radians. | 0 |
custom |
A free number stored on the particle, for custom shaders. | 0 |
Things a GPU particle does not do, on purpose: physics and collision (kill_below_heightmap is the one concession), a ticker, colormaps that follow the particle (colormap is sampled once at spawn), sound and particle emission, kill_* rules. If you need one of them, it is a Custom Particle.
The default shader covers the knobs above. If you want your own motion (curls, orbits, noise), a different look, or per-particle colour logic, point shader at your own core shader: "shader": "my_pack:my_dust" loads assets/my_pack/shaders/core/my_dust.json (plus its .vsh and .fsh), exactly like a vanilla core shader.
Start from Polytone's own files at assets/polytone/shaders/core/gpu_particle.* and change what you need. The record layout differs between game versions, since 1.21.11 replaced the old core shader system.
- The vertex format is
Positiononly and its content is garbage. Derive everything fromgl_VertexID:gl_VertexID >> 2is the quad,gl_VertexID & 3the corner. Witharea, one record isAreaCountquads in a row, so the record slot is(gl_VertexID >> 2) / AreaCountand the quad within the spawn is the remainder. - Records live in
uniform samplerBuffer ParticleData;(declare it in the.vsh, do NOT list it undersamplersin the json; Polytone binds it itself). Slotiis texelsi*4 .. i*4+3:[x y z vx] [vy vz spawnTick lifetime] [seed lightAlpha rgb size] [roll custom 0 0], wherelightAlpha = blockUv + skyUv*256 + alpha8*65536andrgb = r8 + g8*256 + b8*65536(unpack with floor/mod, see the default shader). A slot withlifetime <= 0is empty; the shader must collapse dead and empty slots itself (put all four vertices somewhere off screen). - Polytone sets these uniforms every frame if your json declares them:
Origin,Time,Gravity,Drag(-ln(friction)),SizeEnd(-1 = none),UseColorEnd,ColorEnd,Fade,Sway,Spin,Aspect,Billboard(0 camera, 1 around Y, 2 flat, 3 along velocity),Frames,RandomSprite,AlphaCutoff,AreaCount,AreaSize,KillBelowHeightmap,HeightmapInfo(xy: heightmap origin xz relative to the camera, z: size in blocks, w: world minY),CameraY. Copy their declarations from the default json; types must match. Anything you do not declare is skipped, anything inuniformsis set as afloatof that name. - A
shaderid points atassets/[namespace]/shaders/core/[name].jsonplus its.vshand.fsh, exactly like a vanilla core shader.
There is no shader json any more: a shader id points at assets/[namespace]/shaders/core/[name].vsh and .fsh and Polytone builds the pipeline itself.
- The record is the vertex format, repeated over the four corners of the quad:
in vec3 Position(spawn position, relative toOrigin),in vec3 Velocity,in vec2 SpawnLife(spawn tick, lifetime),in vec4 Params(size, roll, custom, seed),in vec4 Color,in ivec2 UV2.gl_VertexID & 3is the corner. A vertex withSpawnLife.y <= 0is an empty slot; the shader must collapse dead and empty slots itself. - The per-type knobs are one std140 block. Import it instead of writing it out:
#moj_import <polytone:gpu_particle.glsl>gives youOrigin,Time,Gravity,Drag,Sway,Spin,SizeEnd,Aspect,AlphaCutoff,Frames,Fade,Billboard,RandomSprite,UseColorEnd,ColorEnd, same meanings as above. - Everything in
uniformsbecomes its own single-float UBO block, like post shader uniforms:layout(std140) uniform MyName { float value; };. - The usual vanilla includes work:
<minecraft:fog.glsl>,<minecraft:dynamictransforms.glsl>(ModelViewMat,ColorModulator),<minecraft:projection.glsl>(ProjMat).
-
Sampler0is your texture,Sampler1the heightmap (r = low byte, g = high byte of the surface height above minY),Sampler2the lightmap. Fog uniforms are the standard ones, usefog.glslas usual. - Shader packs (Iris) do not shade these; they draw after the world with vanilla-style lighting.