Gaussian splats as a particle system: destructible boss hulls for a Godot roguelite
For the past year I've been making AsterMaster, a beat-driven roguelite on a hex grid (Godot 4.6). The free demo is on Steam, and the game's website renders the same models right in the browser:
- Steam: store.steampowered.com/app/5095060 — the demo is free; the game is in Steam Next Fest October 19–26, full release November 17.
- Website: aster-master.com — trailer, a splat bestiary, press kit.
There's one thing about the game people ask about most often: the hull of the player's drone and of the bosses isn't a mesh. It's a cloud of ~6,000 Gaussians, trained on Blender renders, packed into half a megabyte and drawn in Godot as a single MultiMesh. That's exactly why it can be dented, holed, torn into chunks and put back together — entirely in the vertex shader, with no CPU involvement.
This is a technical write-up: why splats at all in a game where a character is a hundred pixels tall; what the pipeline “Blender → gsplat → custom binary format → Godot Compatibility renderer” looks like; what I had to invent along the way (baked channels, entropy binarization, floater cleanup, a format with 64 precomputed sort orders); how destruction is built on top of it; and what didn't work — with numbers. The pipeline was built for phones (Compatibility renderer, half-float, no runtime sorting) and ended up shipping on Steam and Steam Deck — the constraints turned out to be useful.
A note on scope up front: this isn't a “how to build a photoreal 3DGS scanner” article. It's a story about taking a method from a neighbouring field and using it for something it was never meant for.
Why splats in a game where everything is 100 pixels
I can't draw, so the enemy and drone models are AI-generated (Meshy). They look nice, but come as 25–55 MB high-poly files with hundreds of unclosed “plates” instead of honest topology. You don't ship that in a build, and, more importantly, a mesh dies boringly: play the animation, dissolve, gone. I wanted combat to be readable on the hull itself: a dented side is a story, an HP bar is a report.
Gaussian Splatting (3DGS) represents a scene as a cloud of anisotropic Gaussians: each has a position, a covariance (scale + rotation), a colour and an opacity. The cloud is trained by differentiable rasterization against a set of photos with known camera poses, and it's normally a tool for photoreal scans of reality. I looked at it from the other side: every splat is a separate instance with its own position, colour and size. That's a ready-made particle system in the shape of a model. You can shift it, rotate it in groups, knock splats out one by one and put them back — and a vertex shader already knows how to do all of that.
Hence the requirements, which turned the usual 3DGS pipeline on its head:
- The style is toon, not photoreal. So we train on synthetic renders with exact poses, not on photos. No COLMAP.
- The budget is hard. Not “however many it takes” but exactly N splats per model: 6,000 for a boss, a couple of thousand for small fry.
- The runtime is a mobile GPU. Godot's Compatibility renderer, per-instance data in half-float, transparency is the enemy. Almost all splats have to be opaque and drawn without sorting.
- Extra channels are needed. Normals (for cel lighting and muzzle flashes), which part of the model a splat belongs to, “depth” inside the hull — vanilla splats have none of that.
Day one: five approaches in 24 hours
The research fit into one long day, and the arc is telling.
- splatfacto (nerfstudio) on PBR renders — 41k Gaussians, photoreal. Pretty, useless: no win over a mesh, and the game needs toon anyway.
- splatfacto on cel renders — 16k. Now it has the style, but the budget can't be targeted: the adaptive density control heuristic just stops wherever it likes. Fine for one model on screen; not for a boss with effects and a crowd.
- A custom differentiable superellipsoid rasterizer. The idea: a primitive with an honest sharp edge,
F = (Σ|q|^p)^(1/p), morphing from an octahedron to a rounded box, with analytic normals and “panel” semantics. The result was a charming 500-primitive drone weighing 32 KB that converges — but “with a brush”, no detail. This branch gave the project folder its name (sdf_splat), and it gets its own post-mortem below. - Surfels from the mesh — sample the surface, drop hard-edged quads in the tangent plane, colour from the texture. No training. Rejected within an hour: the hard-edge mosaic reads as dirt, surfels don't blend.
- The winner — a mini-trainer on top of
gsplatwithMCMCStrategy(cap_max=N): a hard budget, 6,000 splats, 21 seconds of training on an RTX 3060, loss twice as good as the custom rasterizer.
The key decisions from that day survived to release unchanged:
- training on synthetic Blender renders with known poses; the style comes from the data (a cel shader in Blender), no spherical harmonics — direct RGB only;
- normals are not learned but snapped from the source mesh after training — which is where the game's dynamic cel relighting and muzzle flashes come from;
- the outline isn't learned either — it's screen-space, on top;
- rendering in Godot: an opaque core with no sorting + a thin semi-transparent “shell” with precomputed orders.
Dataset: headless Blender instead of photos
The dataset is rendered by a headless Blender script: import the GLB, normalize the model into a 1.6 cube, a camera on an orbit of radius 3.2, 1024² with alpha, a transforms_*.json layout like NeRF synthetic.
R = 3.2
def pose(split, i, n):
golden = math.pi * (3 - math.sqrt(5))
if split == 'train':
t = (i + 0.5) / n
smin, smax = math.sin(math.radians(BELT_MIN)), math.sin(math.radians(50))
elev = math.asin(smin + (smax - smin) * t) # uniform in sin = uniform in area
az = i * golden # golden spiral in azimuth
...
return Vector((R * cos(az) * cos(elev), R * sin(az) * cos(elev), R * sin(elev)))
Two things separate a game dataset from “walk around the object in a circle”:
The elevation belt. The game camera looks down at 32–50°, and the model only rotates in yaw. So the underside is never visible, and everything between 32° and 50° has to be covered densely. Train views lie on a golden spiral within that belt; val/test sit at fixed elevations. Later the belt had to be extended down to 14° — more on that in the floater section.
Lighting. A splat learns what it sees, lighting included. I ran three material variants for one drone: cel (a toon shader right in Blender), flat (pure albedo, no lighting) and ao (diffuse under a white dome, no sun).
ao won: baked dome shading gives depth without the directional bands that would double up with the runtime's cel bands. The same recipe was then rolled out unchanged to every mob and boss: each textured material is rebuilt as diffuse(texture × HSV).
Dataset gotchas, each worth an evening:
- On some models the first
TEX_IMAGEnode in the material turned out to be a dark ORM map — the dataset came out black. The right path: start atPrincipled BSDFand follow the Base Color input down to the texture, falling back to the first sRGB image. - “The bosses lack detail.” I blamed the splat budget and the trainer. The real root: the datasets were rendered from the GLBs in the game repo, where textures had long ago been squeezed to 128² by an old optimization pass. The full 2048² sources were sitting in the folder next door. Blur in — blur in the bins.
The trainer: 150 lines on top of gsplat
The whole trainer is one file. Parameters as in the original 3DGS, but colour is direct RGB through a sigmoid, no spherical harmonics. Initialization — samples from the source mesh surface with colour from the texture (one line in trimesh).
params = torch.nn.ParameterDict({
'means': torch.nn.Parameter(pos),
'quats': torch.nn.Parameter(q + torch.randn_like(q) * 0.01),
'scales': torch.nn.Parameter(torch.full((N, 3), math.log(0.03))),
'opacities': torch.nn.Parameter(torch.zeros(N)),
'colors': torch.nn.Parameter(torch.logit(col.clamp(0.02, 0.98))),
})
strategy = MCMCStrategy(cap_max=N, refine_start_iter=500,
refine_stop_iter=steps - 1000, refine_every=100)
for step in range(steps):
vi = np.random.randint(n_views)
# random grey background every step — anti-floater
bg_val = float(np.random.random())
rgb, alpha, info = rasterization(means, quats, scales.exp(), sigmoid(opacities),
sigmoid(colors), viewmats[vi][None], K[None],
width=res, height=res,
backgrounds=torch.full((1, 3), bg_val))
gt = imgs[vi] + (1.0 - alphas_gt[vi])[..., None] * bg_val
loss = (rgb - gt).abs().mean() + 0.3 * (alpha - alphas_gt[vi]).abs().mean()
if entropy > 0: # late annealing: the shape must settle before binarization
ent_w = entropy * max(0.0, (step / steps - 0.6) / 0.4)
o = torch.sigmoid(params['opacities'])
loss = loss + ent_w * (o * (1 - o)).mean()
loss.backward()
strategy.step_post_backward(params, optimizers, state, step, info, lr=lr_means)
for opt in optimizers.values():
opt.step()
Three tricks that made the result playable:
MCMC instead of cloning. gsplat's MCMCStrategy doesn't multiply Gaussians; it relocates “dead” ones (low opacity) onto “live” ones — the splat count never exceeds cap_max. The budget becomes an input parameter rather than an outcome.
Random grey background. The first trainer composited over black and got “proxy shadows”: a dark splat hung in empty space under a gun costs almost nothing in the loss (RGB error ≈ 0, only a weak alpha penalty remains) and helps shade the hull a little. Over a random background any out-of-silhouette splat is expensive in RGB from every view. ~25 splats remained outside the silhouette instead of ~40; the trainer can't get it to zero — at 400 px a sub-pixel dot is nearly free in the loss against any background.
Entropy penalty on opacity. o·(1−o) with late annealing (the last 40% of steps) pushes opacities to 0 or 1. At the end ~98% of splats are fully opaque. This isn't aesthetics — it's the key to rendering without sorting, below. Enable the penalty from the start and it fights the alpha loss on the soft silhouette edges, and the shape “never settles”.
After training — a post-hoc normal snap: for each splat centre, find the nearest point on the normalized mesh (trimesh.proximity.ProximityQuery) and take the face normal. Normals take no part in the loss and cost the training nothing, but give the game honest cel lighting and flashes.
Numbers: 6,000 steps at 400² — 21–22 seconds on an RTX 3060, loss 0.003–0.011 depending on the model. Retraining all five bosses as a batch — three minutes. That's what made it possible to try datasets, budgets and penalties by the dozen: the “change a parameter — look” loop is shorter than a coffee.
Export: what else you can bake when you have the source mesh
Export is the fattest script in the pipeline, and that's no accident. Ordinary 3DGS has only photographs as “ground truth”. I have the source mesh itself — so anything that can be computed from the mesh can be baked into the splats.
Core and shell
Splats are split by opacity: opacity > 0.9 is the core, the rest is the shell. After the entropy penalty the shell is ~200 splats out of 6,000.
The core is drawn as opaque octagons in a MultiMesh with a regular z-buffer, no sorting and no discard. In the first version the core was quads with a Gaussian falloff and alpha scissor — 86% of the quad's area went to discard, and on tiled GPUs discard breaks early-Z. The fix: at export, compute the radius of the visible disc from the opacity and bake it straight into the instance axes:
# Old pipeline: quad at 6σ, alpha = op·exp(−4.5 r²), scissor 0.45
# → visible disc r̂ = sqrt(ln(op/0.5)/4.5) of the quad's half-size (~14% of the area).
# New: an octagon fitted to that disc (+20% margin), no discard.
r_hat = np.sqrt(np.log(np.maximum(op[core], 0.55) / 0.5) / 4.5)
ax0[core] *= (r_hat * 1.2)[:, None]
ax1[core] *= (r_hat * 1.2)[:, None]
The shell is drawn alpha-blended with a Gaussian falloff in the fragment — it needs back-to-front sorting. Here a property of the game helps: the camera has a fixed tilt and the model only rotates about the vertical. So the draw order depends on a single number — the heading. At export, 64 sort orders are computed (one per 5.6° sector); at runtime, when the sector changes, the MultiMesh buffer is reordered by a copy. No on-the-fly sorting.
A side effect of the opaque core: MSAA became meaningful — it smooths the seams between octagons and cures the shimmer when the model sways.
Belly cull and importance order
Since the camera belt is known (32–50°), a splat whose normal never faces any direction in the belt is never visible. That's the underside, and it can be thrown away for good: minus ~10% of splats. The remaining core is sorted by importance (opacity × area of the two major axes), so that runtime LOD is one line: visible_instance_count trims the tail of small stuff.
Four baked channels
In Godot a MultiMesh instance has a transform, a colour and one custom_data vec4. The transform carries the splat axes (the two major covariance axes; the normal is the third), the colour carries albedo and opacity. Into custom_data go four numbers that became the fuel for every effect:
| Channel | Where it comes from | What it's for |
|---|---|---|
| part 0..7 | k-means over mesh islands (islands aren't split) or hand-labelled semantic parts | rigid group animation, per-part damage, indestructible guns |
| emis 0..1 | a heuristic on the learned colour: saturated + bright + rare relative to the model's median colour | lamps, chevrons, lenses — things that glow and go dark with wear |
| ao 0..1 | a cosine hemisphere of 24 rays over the mesh (embree) | a “depth” mask: light leaking from seams on charge-up, innards in breaches |
| edge 0..1 | convex vertex defects of the mesh | panel edges, wear |
“Rarity” in emission is a lesson of its own. The first heuristic, “saturated + bright”, lit up a saturated-red boss in its entirety: 2,572 glowing splats out of 4,200. The right semantics of an accent is a colour far from the hull's median colour. With rarity added, 877 remained: the eyes, the lightning, the jaw.
Winding repair — the story of one bug
After moving the bosses to splats, one of them started “bulging splats outward” when hit: a crater should push splats inward along the normal, but they were being pushed out. Diagnosis: the generated GLBs are assembled from hundreds of small unclosed “plate” components (Crimson Skull has 558), and on ~40% of the plates the vertex winding is oriented inward. The post-hoc snap inherited the flipped normals, and everything depended on them: the cel band, the AO rays (shooting into the solid), the belly cull, the crater direction.
trimesh.repair.fix_normals is powerless here — volumetric voting doesn't work on open shells. The custom repair took twenty lines: for each component, an area-weighted vote on dot(normal, center − centroid); if the sum is negative, flip the winding of the whole plate; then flip the splat normals against the nearest, now honest, face. 56% of the Skull's splats flipped; 45% on another boss.
comps = trimesh.graph.connected_components(mesh.face_adjacency, min_len=1,
nodes=np.arange(len(mesh.faces)))
fc, fn, fa = mesh.triangles_center - mesh.centroid, mesh.face_normals, mesh.area_faces
faces = mesh.faces.copy()
for cf in comps:
if np.sum(np.einsum('ij,ij->i', fn[cf], fc[cf]) * fa[cf]) < 0.0:
faces[cf] = faces[cf][:, ::-1] # the plate faces inward — flip it
mesh = trimesh.Trimesh(mesh.vertices, faces, process=False)
Floater cleanup: three signs and one lesson
A floater is a splat hanging in the air next to the model. On an intact model you don't see them on a black stand; in the game, on the arena's grey floor, they read as garbage, and destruction makes more of them. It turned out a floater has three independent signs, and you need to catch all three:
- Far from the mesh. Distance to the surface, p50/p99, for a healthy cloud — 0.013/0.057 (in units of the 1.6 model); anything beyond 0.055 is junk.
- Detached. A neighbourhood graph over a grid hash; only the main component survives. The production avatar had ghost clusters of 22–24 splats hovering next to the hull.
- Sticking out of the silhouette. A ray from the game's viewpoint through the splat centre misses the mesh. The view grid has to be dense — 72 azimuths × 5 elevations: a floater lives in a sector as narrow as 5°, and 12 azimuths missed three quarters of the hangers.
Plus a fourth pass over the dataset itself: project the splat into each of the 200 train views; if the disc lies entirely on transparent background — it's a floater the trainer didn't kill because it's sub-pixel.
The most effective fix turned out to be in the dataset, not the export: extend the camera belt downward from 32° to 14° and raise the number of train views from 120 to 200 (so the density in the game belt doesn't thin out). Low grazing views give the trainer silhouette supervision from below: a splat under a gun barrel sticks out against empty background, and the loss kills it on its own. Splats sticking out of the silhouette: 18 before the retrain → 9 after → 7 after the silhouette cull; the remainder are legitimate edges of thin barrels.
The SPL2 format
The output is a JSON for debugging and a binary .bin: magic SPL2, three int32 (n_core, n_shell, n_buckets), then, for the core and the shell, float32 blocks pos / ax0 / ax1 / normal / color / opacity / attrs, then 64 shell sort orders as int32, then an optional CHNK tail (materials and chunks — below). Alongside, a meta sidecar: the normalization scale and offset relative to the source GLB (so the splat node lands in the scene exactly where the mesh was), barrel tips, the gun axis, the engine box.
Weight: the player's drone — 5,056 core splats + 203 shell ≈ 0.5 MB; bosses ~0.4 MB each. The source GLBs are 25–55 MB.
Rendering in Godot: MultiMesh, Compatibility and three gotchas
A splat model is a regular Node3D with two MultiMeshInstance3D (core, shell) living in the entity's SubViewport. Loading the bin is a few PackedByteArray.slice(...).to_float32_array():
var buf := FileAccess.get_file_as_bytes(bin_path)
assert(buf.slice(0, 4).get_string_from_ascii() == "SPL2")
var n_core := buf.decode_s32(4)
var n_shell := buf.decode_s32(8)
var n_buckets := buf.decode_s32(12)
var off := 16
for key in ["core", "shell"]:
var n: int = n_core if key == "core" else n_shell
for field in ["pos", "ax0", "ax1", "normal", "color"]:
parts[key][field] = buf.slice(off, off + n * 12).to_float32_array(); off += n * 12
parts[key]["opacity"] = buf.slice(off, off + n * 4).to_float32_array(); off += n * 4
parts[key]["attrs"] = buf.slice(off, off + n * 16).to_float32_array(); off += n * 16
for b in n_buckets:
orders.append(buf.slice(off, off + n_shell * 4).to_int32_array()); off += n_shell * 4
The core shader is unshaded; cel lighting is computed from the baked normal: three bands by N·L of the key light plus a dynamic flash from a point source (a muzzle, a nearby explosion) — that flash is what the normals were baked for.
float ndl = clamp(dot(v_normal, -normalize(light_dir)), 0.0, 1.0);
float band = mix(0.62, 0.88, smoothstep(0.22, 0.28, ndl)); // narrow ramps instead of steps — anti-pop
band = mix(band, 1.12, smoothstep(0.52, 0.58, ndl));
vec3 col = v_col * base_brightness * band;
col += v_col * v_emis * (0.78 + 0.22 * sin(TIME * 2.3)) * 1.25; // lamps breathe
vec3 fd = flash_pos - v_world; // muzzle flash:
float fndl = clamp(dot(v_normal, normalize(fd)), 0.0, 1.0); // a hard cel band
float fband = fndl > 0.5 ? 1.0 : (fndl > 0.2 ? 0.45 : 0.0); // from a point source
col += flash_color * flash_strength * fband / (1.0 + 0.15 * dot(fd, fd));
Now the gotchas. All three are about the Compatibility renderer, and each cost a day.
1. custom_data is stored as half-float. Discovered not on splats but on a neighbouring experimental branch (the volumetric lattice, below): the Godot port produced “confetti” with correct data. A full CPU reference of the same math and a per-pixel diff showed that integer indices above 2048 are silently rounded (4482 → 4480), and 57% of instances read other cells' attributes. Since then it's project law: any baked channel ≤ 255, and part and emis are packed into one number as part*16 + emis*15.
2. Gamma. When the desktop build moved from Compatibility to Forward+, splats went “almost white” while toon-shaded meshes didn't. A probe of four grey quads in a SubViewport showed: GL Compatibility is a pure gamma pipeline (a 0.5 constant comes out as 0.502), while Forward+ treats raw inputs as linear and sRGB-encodes on output (0.5 → 0.737), decoding only source_color inputs. Splats take colour from instance COLOR and constants — hence the brightening along the curve. The fix: all inputs raw, math in gamma space as it was tuned, final output through a custom gamma_out() with a global switch.
3. .bin isn't a Godot resource. Without an explicit include filter, export silently leaves it out of the build. On device the models lived on the GLB fallback, and only the avatar was a splat (it had a JSON fallback). Caught one day before the fallbacks were deleted — otherwise I'd have shipped “empty mobs” in the release.
On top of that, a set of mobile commandments that stayed in force on desktop too, because the shaders are shared: loop bounds only as literals, hashes without sin in long-running effects, no pow per pixel, no DEPTH writes from the fragment.
The cost per frame: a splat boss is 6,000 instances, one draw call for the core and one for the shell; on an RTX 3060 the whole scene with a boss and a crowd holds a 4.6–5.4 ms median with ×2 viewports and MSAA 8×. For the Steam Deck pass the core got a variant with round splats via alpha_to_coverage — smoother silhouettes, no depth artifacts, +0.4–0.8 ms across all entities.
Effects: splats as a material
All hull animation is one vertex shader and a set of uniforms; the CPU just turns the knobs. Each effect is a separate if (uniform > 0) branch with zero defaults, so a static mob doesn't pay for them.
- scatter / implode / assemble — death, collapse before detonation, materialization on spawn. It's one and the same scatter along a per-splat random direction, played in different directions; scatter has four modes (radial, a ring in the arena plane, a firework column, a vortex) and a kinetic bias along the vector of the killing shot.
- ripple — a narrow front runs across the hull from the hit point, shifting splats along their baked normals.
- charge — panels levitate along the normal, the innards glow through the gaps. The mask is
1 − ao: only “deep” splats glow. The same channel, recoloured red, became the kamikaze's telegraph: “raised panels = about to blow”. - link_decay — neural degradation. The first version with a smooth sine “ripple” read as jelly; the one that works is a packet glitch: time is quantized into slots, in each slot a hash-picked hull part discretely twitches and fades to cyan, plus a scatter of single “corrupted packets”.
- siphon — the ultimate's singularity: a fraction of the splats flows toward the centre along a spiral, stretching into a thread; a
fractphase of the cycle gives a teleport back to the source without the CPU. - part_xform — eight rigid “bones” per model with no skeleton. Uniform arrays of pivot/quaternion/offset by the part channel: mosquito wings flap, the constructor's ring floats up and spins, the drone's barrels recoil on fire.
if (part_anim > 0) {
int pp = clamp(int(pid), 0, 7);
vec4 q = part_quat[pp];
vec3 dv = VERTEX - part_pivot[pp];
VERTEX = part_pivot[pp] + dv + 2.0 * cross(q.xyz, cross(q.xyz, dv) + q.w * dv) + part_offset[pp];
v_normal += 2.0 * cross(q.xyz, cross(q.xyz, v_normal) + q.w * v_normal);
}
That's “skinning with one bone per part and binary weights”. A rigid rotation of trained splats is mathematically exact — the picture doesn't degrade, unlike soft skinning, which would bend the Gaussians' orientations. I rejected soft skinning as over-engineering, and in three months it hasn't been needed once.
Destruction: craters, the onion, the support graph and chunks
This is what it was all for. Destruction lives in a shared module (the avatar, the bosses and the rival use the same code) and treats the core as an array of slots: each slot is a MultiMesh instance whose transform, colour and damage channel can be changed.
Craters
hull_hit(dmg, dir, kind): the attack type defines the geometry. SHOT — a narrow band of candidates on the attacked side (projection onto the side's normal); BLAST — a wide one; EPICENTER (a mine under the belly, a lobbed shell) rips off the top; MELEE — a dent; PIERCE (the railgun) — spall at the entry, a channel of cracks along the slug's path, a chunk at the exit. A knocked-out splat either flies off into the debris pool (born white-hot and cooling in flight along a black-body curve) or is pushed inward.
The key visual fix of the very first version: a knocked-out splat doesn't vanish, it's pushed in = the dark bottom of a crater. Otherwise the holes show the background through, and the hull looks not damaged but perforated.
The onion
An idea from a playtest: “a knocked-out piece flies off, and in its place is the same thing but sparser and darker; each time the hole gets deeper and the internal structure shows.” Implemented without growing the instance budget: a knocked-out slot reincarnates one layer deeper — position = plating + inward × step × level, where inward is minus the normal blended toward the centroid, and the step is proportional to the hull thickness at that point. The slot stays alive and is again a hit candidate, so “shoot into the hole — dig deeper” comes for free on the same mechanic.
The layers are procedural rules: a dark underlay → a frame (elongated “ribs”; for metal, along a structural lattice with a deterministic pitch, so the ribs of neighbouring pieces join into one construction) → innards → a near-black core with smouldering hot spots and rare cold sparks. You can't train splats for depth: generated GLBs have no ground truth for the insides — it's invented either way.
The support graph
After the onion came “hanging” splats: plating whose neighbours have all been pushed two layers down is formally connected to the hull through depth, but visually floats over a pit. Zone sweeps around the last hit didn't catch it. The solution: the whole model's neighbourhood graph is built once on load (a grid hash, the radius self-calibrates from N), and after that every slot death or deepening checks its neighbourhood event-driven, O(neighbours): a cut-off island of up to 24 splats tears off as a chunk, an unsupported splat settles to its neighbours' level, innards go dark when their parent region is knocked out completely.
From the same series — per-part accounting: a boss with two guns has them as two semantic parts; knock out 30% of the right one's splats and the right barrel goes silent until the end of the phase. A reward for aimed damage that didn't cost a single new data structure.
Chunks instead of confetti
The version above lived for two months and mostly worked, but at the playtest before the demo someone said “the destruction reads as mush”. Diagnosis from crops: per-splat debris is confetti; onion layers have no structure; the damage palette is red on a purple-red hull; the hole's rim is “nibbled” by missing ellipses. The answer — a second layer of baked data.
Materials. A material class per splat from the training albedo (a recipe per model: chitin/bone/flesh for an organic boss, paint/electronics for mechanical ones) with majority smoothing over neighbours. Six classes: default, chitin, bone, flesh, circuit, core.
Chunks. k-means++ over positions within each material and semantic part, area-weighted — chunks of equal area, not equal count: on plates a chunk = a plate, on teeth and claws — their own small ones. The borders are ragged: the last Lloyd iterations compute distance with per-splat jitter, then a smoothing pass. Chunk size is the key to readability: 25 splats ≈ 10 px on screen — didn't read; 100 ≈ 20 px — a piece.
Packing — into the alpha of instance COLOR: 1 + chunk*8 + mat (≤ 2048, f16-exact; old bins carry 1.0 → chunk 0 / material 0). The CHNK tail after SPL2 is ignored by old loaders — the format stayed compatible.
Now a hit cracks a chunk (rim → crack line, a couple of chips), and with enough budget tears it off whole as a rigid body: the piece carries its plating and its underside (a copy with the normal flipped inward — a tumble shows flesh or frame), only the rim is ragged, the chunk's innards stay in the hull's masks. Weapon profiles: the cannon — a crack plus a cascade (a cracked chunk tears off first on the next shot), the mortar — radially from the epicentre, the railgun — spall at the entry and a chunk at the exit along the slug's path.
The splat's shape became data too: a class by size percentile within the model (a plate ≥ p85 — a 5–7-gon, a dot ≤ p40 — a circle, in between — a rounded hex), the mask is computed in the fragment via alpha-to-coverage. An intact hull is smooth; raggedness of the outline is switched on by state — the rim of a wound and the floor of a crater — not by idle noise. That's a lesson from an earlier playtest: “the splats wander” — any per-splat shimmer at rest reads as colour noise.
What didn't fly
The most useful part, in my opinion.
SDF superellipsoids
The branch with the custom rasterizer lost to Gaussians for four pragmatic reasons, none of them fundamental:
- Speed, ×100. My rasterizer did a global sort and front-to-back over all primitives per ray, no tile binning; at 400² it hit the VRAM ceiling (sampling 50k rays per step), 0.7 s/step, 30+ minutes per run. gsplat is a tiled CUDA kernel, 6,000 steps in 21 s. All the later ablations were possible only on the 21-second loop.
- Quality per budget: loss 0.0137 vs 0.008. Gaussians have smooth, well-conditioned gradients. A superellipsoid's
α = sigmoid(k(1−F))with k annealed from 8 to 60 gives either blur at small k or dead gradients at large k (the sigmoid saturates). Manual primitive resuscitation lost to MCMC relocation. - The runtime wall: depth. A Gaussian is a quad in a MultiMesh. A superellipsoid needs either ray marching in the fragment (
powper pixel — the red zone of mobile GPUs) or outputting an analytic iso-depth from the fragment for an honest z-buffer. The same wall killed the lattice's opaque pass. - Colour detail. 500–1,000 constant-colour primitives can't carry decals and panel paint; 6,000 Gaussians carry the detail in their colours.
Half the findings made it to production in baked form: analytic normals → normals from the mesh; “panel = primitive” → part ID; exact depth without sorting → entropy binarization. And the 32-kilobyte drone is waiting for a hybrid — “SDF hull panels + a Gaussian shell” — after release.
The volumetric lattice
The second branch: replace the free cloud with a sparse hierarchical lattice (octree/AMR style). Colour and density at cell vertices, position implicit (encoded by the index), draw order known from the tree structure — no sorting, LOD for free. A custom CUDA rasterizer, training with pruning (the drone's lattice shrank from 10,000 cells to 4,000), a port to Godot.
The density version worked and was even more stable over time than splats (flicker at long range three times lower). The version with an SDF in the cells, which was supposed to kill transparency as a concept by writing depth from the fragment, broke on the renderer: a ray pierces 15–20 overlapping cells, and Compatibility doesn't resolve that stack — a mosaic with holes and moiré. The lattice contributed that half-float bug and two weeks of work. The sorting win I'd already obtained more cheaply by then — through entropy binarization.
Regular mobs went back to meshes
For exactly one week all enemies were splats. Then the regular mobs went back to GLBs with a toon shader: at 60–90 px splat destruction doesn't read and doesn't pay for its content cost, and the main risk on mobile GPUs isn't vertices but ten semi-transparent models at once. Splats stayed where destruction is visible: the player's drone, the five bosses, the rival. The mesh toon shader repeats the splat formula so the two renderers don't argue in one frame.
Also rejected along the way: a splat shadow (a second MultiMeshInstance over the same buffer, projected onto the floor along the light — works, but a simple 2D shadow reads no worse), rim glow (exposes the primitive structure), CoACD chunks with baked vertex colour (they smear the texture), pre-rendered sprites instead of mobs (quantization to 16–24 directions next to live splat bosses reads as alien).
Bonus: the same splats in the browser
The website aster-master.com deserves a separate mention, because it's built on the same data. It has one job — wishlists and the “play the demo” button — so the first screen is pure static with no WebGL dependency, and the 3D is a lazy chunk on top. The metaphor is a descent into the mine: the page is a vertical shaft, sections are horizons with depth in metres (the trailer at 35 m, mechanics at 80 m, the bestiary at 125 m, the lair at the bottom), the camera follows native scroll with damping, and a depth-gauge HUD shows where you are. The stack is Vite, vanilla JS and three.js, no frameworks; all bestiary content (names, stats, descriptions in two languages) isn't stored on the site but assembled from the game's tables at build time, so an edit in the game reaches the site with a rebuild.
That's also where it became clear that splats are the best model representation available: the game GLBs carry 128² textures and don't survive close-ups, while the splats were trained on detailed renders. The drone on the site is that very 16k cloud from day one, cleaned of floaters and packed into SPZ (229 KB; the packer is my own, in Python, byte-for-byte checked against the reference layout). Rendering is Spark on top of three.js, and the effects follow the same principle — “one GLSL block per Gaussian”: assembly from a swarm of sparks bottom-up, the lair's crimson light, a visor pulsing to the beat. The bestiary mobs are ~39 KB each, straight from the training splats.json; you can spin them with the mouse on the “tactical terminal”, and switching mobs is a scatter and reassembly from sparks. The boss at the bottom of the site (Crimson Skull) is 85 KB.
Inside the game the same approach has one more consumer — the hologram decoy: not a snapshot sprite but a live splat model, with a separate cloud trained for a budget of 1,800 splats (a retrain with cap_max=1800 rather than a top-1800 slice of the 6,000 bin — MCMC distributes the budget itself; a slice leaves holes).
Takeaways
| Splats per model | 6,000 (bosses, drone), 1,800 (hologram), 2,800 (mob trials) |
|---|---|
| Opaque after the entropy penalty | ~98% |
| Training one model | 21–22 s (RTX 3060, 6,000 steps, 400²) |
| Dataset | 120–200 views at 1024², headless Blender, ~30 s per model |
| Weight in the build | ~0.5 MB per model vs 25–55 MB GLB |
| Baked channels | part / emis / ao / edge + material and chunk |
| Size on screen | drone 72 px, bosses ~112 px |
| First experiment → production visuals | 5 days; to “chunks” — 2 months |
What I'd do differently from the start:
- Judge the silhouette only on a light background and only at game scale. Half the floater iterations were a fight against things you can't see on the stand.
- Check which file the dataset is rendered from. The 128² texture lesson cost a retrain of every boss, though the trainer was never at fault.
- Compute destruction in chunks from the start, not in splats. Per-splat debris is a pretty demo and mush in combat; the size of a readable piece (≈20 px) is the first thing worth measuring.
- Don't be afraid to bring constraints into the game instead of capabilities. The fixed camera gave 64 precomputed sort orders, the elevation belt gave the belly cull, the toon style gave the absence of spherical harmonics. Every “we can't” turned into “we don't need to”.
You can play the result for free: the AsterMaster demo on Steam (the game is in Steam Next Fest October 19–26, release on November 17), and the site with those same splats in the browser — aster-master.com.
Lastly — honestly, why this venture was the most fun thing of the year. Not because splats are “unusual” (though yes). Because it's a rare case where a method from someone else's field fit the game's constraints better than the native one: every “a phone can't do that” turned into a baked table, and every baked table into a new effect that didn't cost a single frame. Three months ago I didn't know what gsplat was; now the bosses have insides, and you can open them up with a railgun. A significant part of the code in this article was written in pair with an AI coding agent (Claude Code) — not a secret and not an ad, just that one pair of hands can't pull off that volume of experiments in a summer; the decisions, the taste and the playtests are mine, and we share the blame for the gotchas.
Questions about gsplat, Godot's Compatibility renderer, or how to make a point cloud look like dented metal — ask in the thread where you found this post, or write to info@aster-master.com. I'll answer everything.
Transparency: as disclosed on the Steam page, some promo art and item icons, a few source 3D meshes, the background music and some flavor text were made with generative-AI tools and curated by me; nothing is generated at runtime. The splat pipeline, shaders and destruction described here are my own work, with part of the code written in pair with an AI coding agent, as noted above.
— zakons, solo dev of AsterMaster · Russian original on Habr
AsterMaster — a beat-driven roguelite shooter on a hex grid. Free demo out now · Steam Next Fest October 19–26 · full release November 17, 2026.
Play the free demo Wishlist on Steam
Free demo on Steam