Skip to content

GPU Particles

MehVahdJukaar edited this page Sep 17, 2026 · 7 revisions

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.

Setup

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

Initializer

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.

Custom shader

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.

1.21.1

  • The vertex format is Position only and its content is garbage. Derive everything from gl_VertexID: gl_VertexID >> 2 is the quad, gl_VertexID & 3 the corner. With area, one record is AreaCount quads in a row, so the record slot is (gl_VertexID >> 2) / AreaCount and the quad within the spawn is the remainder.
  • Records live in uniform samplerBuffer ParticleData; (declare it in the .vsh, do NOT list it under samplers in the json; Polytone binds it itself). Slot i is texels i*4 .. i*4+3: [x y z vx] [vy vz spawnTick lifetime] [seed lightAlpha rgb size] [roll custom 0 0], where lightAlpha = blockUv + skyUv*256 + alpha8*65536 and rgb = r8 + g8*256 + b8*65536 (unpack with floor/mod, see the default shader). A slot with lifetime <= 0 is 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 in uniforms is set as a float of that name.
  • A shader id points at assets/[namespace]/shaders/core/[name].json plus its .vsh and .fsh, exactly like a vanilla core shader.

1.21.11 and up

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 to Origin), in vec3 Velocity, in vec2 SpawnLife (spawn tick, lifetime), in vec4 Params (size, roll, custom, seed), in vec4 Color, in ivec2 UV2. gl_VertexID & 3 is the corner. A vertex with SpawnLife.y <= 0 is 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 you Origin, Time, Gravity, Drag, Sway, Spin, SizeEnd, Aspect, AlphaCutoff, Frames, Fade, Billboard, RandomSprite, UseColorEnd, ColorEnd, same meanings as above.
  • Everything in uniforms becomes 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).

Both

  • Sampler0 is your texture, Sampler1 the heightmap (r = low byte, g = high byte of the surface height above minY), Sampler2 the lightmap. Fog uniforms are the standard ones, use fog.glsl as usual.
  • Shader packs (Iris) do not shade these; they draw after the world with vanilla-style lighting.

Clone this wiki locally