A level-up halo is easy to get right on the screen you're developing on. You place the renderer, set the projection origin to the portrait's position, and it sits nicely behind the face. Then the HUD scales for a phone or a TV, the card moves, and the halo stays where it was.
This post compares three ways to attach a NixieFX halo to a PixiJS level-up card, measured at three HUD scales. It also checks two things that come up once the card is part of a real UI: a resize while the halo is already playing, and a fade-in on the card.
Setup: PixiJS 8.22.0, nixie-fx 0.1.21, headless Chrome 155 with SwiftShader WebGL, fixed 1/60 s steps and a fixed seed. The card is 240 x 280 design pixels: a 56 px radius portrait, a LEVEL 8 label with no backing plate, and a Continue button. The full scene code and the effect JSON are at the end.
The effect
Two one-shot emitters, both additive, gravity and drag set to 0 explicitly:
- glow: one soft particle, 0.9 s lifetime, 150 px across growing to 1.45x, alpha peaking at 0.6
- motes: 16 small particles from a ring just outside the portrait, drifting outward
I built it as NixieFX authoring JSON. If you'd rather tune it by eye, a visual particle editor for PixiJS such as NixieFX saves the same JSON format to your project folder. npx nixie-fx validate . reported 0 errors and 0 warnings, and the export's support report says supported for both the Pixi and Three backends, with no partial or unsupported modules. Nothing in this effect gets silently dropped at runtime.
Three ways to place the renderer
A. Stage root, origin read once. The renderer lives in a layer under the card at the stage root. At startup the code reads the portrait's global position and uses it as the projection origin:
const g = portrait.getGlobalPosition(); const vfx = new PixiVfxRenderer({ parent: stageHaloLayer, projection: createPixiVfx2dProjection({ originX: g.x, originY: g.y, pixelsPerUnit: 100, yAxis: "up", }), }); B. Stage root, projection updated on resize. Same layer, but every layout change calls setProjection with the portrait's new global position and a scaled pixelsPerUnit:
function relayout(scale, x, y) { card.scale.set(scale); card.position.set(x, y); const g = portrait.getGlobalPosition(); vfx.setProjection(createPixiVfx2dProjection({ originX: g.x, originY: g.y, pixelsPerUnit: 100 * scale, yAxis: "up", })); } C. Inside the card. The renderer gets its own container inside the card, added before the portrait, so it draws behind it. The projection origin is the portrait's local position, and the card's own transform does the rest:
const cardHaloLayer = new Container(); card.addChild(cardHaloLayer, portrait, label, button); // halo first = behind const vfx = new PixiVfxRenderer({ parent: cardHaloLayer, projection: createPixiVfx2dProjection({ originX: 120, originY: 100, pixelsPerUnit: 100, yAxis: "up", // card-local }), }); What happened at three HUD scales
The card starts at 1.0x. Then it's re-laid out to 0.75x (bottom left, as on a phone) or 1.5x (as on a TV), and a level-up fires. Measured 0.3 s after the level-up, from the glow particle's debug quad mapped to screen space:
| Layout | A: stage, origin once | B: stage + setProjection | C: inside the card |
|---|---|---|---|
| desktop 1.0x | centred, 1.48x portrait | centred, 1.48x | centred, 1.48x |
| phone 0.75x | 192 px off, 1.98x portrait | centred, 1.48x | centred, 1.48x |
| tv 1.5x | 122 px off, 0.99x portrait | centred, 1.48x | centred, 1.48x |
"1.48x portrait" means the glow's radius is 1.48 times the portrait's radius on screen, which is what the effect was designed for. In A the glow keeps its startup size and position. At 0.75x it floats in empty space. At 1.5x it's almost exactly the size of the portrait, so the opaque portrait hides nearly all of it.

0.75x layout, 0.3 s after the level-up. Same effect file, same seed.
B and C produced identical numbers in every case. I also resized the card to 0.75x while a halo was already 0.15 s in. In B, setProjection moved the particles that were already alive, and they finished centred on the new position. C needs nothing for this case. A stayed 192 px off.
The label and the button
The glow reaches below the portrait. In layout C at 1.0x its lower edge sits at y = 283 px. The label occupies 268 to 298 and the button starts at 322, so the halo passes behind the label and stops well short of the button. That held at every scale for B and C: the glow ends 29 to 58 px above the button.
For the label I measured worst-case contrast. Each frame, I hid the label, rendered, read the pixels inside the label box with app.renderer.extract.pixels, took the brightest one and computed the WCAG ratio against the label colour #ffe7a3. With no halo the label measures 14.97:1. With the halo it bottomed out at 9.77:1 at 1.0x and 0.75x, and 9.62:1 at 1.5x. Both are comfortably above 4.5:1.
One thing that will skew this measurement: extract only reads what's in the stage. The background you pass to app.init isn't part of it, so without an opaque background shape in the stage, additive particles get measured against transparent black and look higher-contrast than they really are. The demo's first child is a full-size background rectangle for that reason.
A fade-in separates the stage layer from the card layer
Level-up cards usually fade or scale in. I set card.alpha = 0.4, fired the halo and measured mean luminance in a small box just left of the portrait:
- A (stage root): 0.0297 at alpha 1 and 0.0297 at alpha 0.4. The halo ignores the card's alpha. B uses the same stage-level layer, so it inherits nothing from the card either.
- C (inside the card): 0.0297 at alpha 1 and 0.0128 at alpha 0.4, against a background of 0.0075. It fades with the card.

card.alpha = 0.4. On the left the glow also shows through the semi-transparent portrait.
You could also sync alpha in B by hand. That means one more property to mirror on every change, along with position and scale, so I'd use C unless the halo has to escape the card's bounds or masks.
Limits
- Software rendering, fixed steps. No frame-time numbers and no real GPU or phone. The geometry and alpha results don't depend on the GPU, but performance does.
- Uniform scale only. I used
card.scale.set(s). A non-uniform scale would stretch the halo in C. I didn't test it, and B would need its own handling. - One resolution.
resolution: 1, noautoDensity. I didn't test device pixel ratio 2. - Worst-pixel contrast is a comparison tool, not an accessibility audit. It ignores font weight and anti-aliasing.
- Masks and filters on the card would also apply to the halo in C. That's usually what you want, but check it if the card has a mask tighter than the glow.
For loading the export bundle and the renderer options used here, the NixieFX PixiJS particle effects guide walks through the same loadVfxExportBundle and PixiVfxRenderer setup.
Source
Scene setup for layout C (PixiJS 8.22.0, nixie-fx 0.1.21)import { Application, Container, Graphics, Text } from "pixi.js"; import { loadVfxExportBundle } from "nixie-fx/export"; import { PixiVfxRenderer, createPixiVfx2dProjection } from "nixie-fx/pixi"; import manifest from "./vfx/manifest.json"; import haloJson from "./vfx/effects/level-up-halo.json"; const bundle = loadVfxExportBundle( { manifest, effectsByPath: { "effects/level-up-halo.json": haloJson } }, { requiredBackend: "pixi2d", requiredEffectIds: ["level-up-halo"] }, ); const halo = bundle.effectsById.get("level-up-halo"); const app = new Application(); await app.init({ width: 640, height: 480, background: "#17131f", antialias: true }); document.body.appendChild(app.canvas); const bg = new Graphics().rect(0, 0, 640, 480).fill("#17131f"); // so extract() sees it const card = new Container(); app.stage.addChild(bg, card); const cardHaloLayer = new Container(); const portrait = new Graphics() .circle(0, 0, 56).fill("#3a2f4a") .circle(0, -12, 20).fill("#c8a27a") .ellipse(0, 34, 34, 22).fill("#5b4a78") .circle(0, 0, 56).stroke({ color: "#d9b25c", width: 3 }); portrait.position.set(120, 100); const label = new Text({ text: "LEVEL 8", style: { fill: "#ffe7a3", fontSize: 18, fontWeight: "700", letterSpacing: 2 }, }); label.anchor.set(0.5); label.position.set(120, 183); const button = new Container(); button.addChild(new Graphics().roundRect(0, 0, 120, 40, 8).fill("#d9b25c")); const buttonText = new Text({ text: "Continue", style: { fill: "#1d1508", fontSize: 16, fontWeight: "700" } }); buttonText.anchor.set(0.5); buttonText.position.set(60, 20); button.addChild(buttonText); button.position.set(60, 222); card.addChild(cardHaloLayer, portrait, label, button); const vfx = new PixiVfxRenderer({ parent: cardHaloLayer, projection: createPixiVfx2dProjection({ originX: 120, originY: 100, pixelsPerUnit: 100, yAxis: "up" }), }); export function layoutCard(scale, x, y) { card.scale.set(scale); card.position.set(x, y); } export function levelUp(level) { label.text = `LEVEL ${level}`; vfx.createEffect(halo, { position: [0, 0, 0] }); } app.ticker.add((t) => vfx.update(t.deltaMS / 1000)); layoutCard(1, 200, 100); { "app": "vfx-editor", "kind": "particle-effect", "version": 1, "targetProfile": "pixi-ui-2d", "id": "level-up-halo", "name": "level-up-halo", "emitters": [ { "id": "glow", "name": "Halo glow", "loop": false, "duration": 0.9, "maxParticles": 1, "render": { "blend": "additive", "depthTest": false, "depthInk": false }, "forces": { "gravity": 0, "gravityValue": { "mode": "constant", "value": 0 }, "drag": 0, "dragValue": { "mode": "constant", "value": 0 } }, "modules": { "color": true, "size": true, "velocity": false, "rotation": false }, "spawn": { "rate": 0, "bursts": [ { "time": 0, "count": 1, "cycles": 1, "interval": 0, "probability": 1 } ], "shape": "point" }, "initializeParticle": { "lifetime": { "mode": "random", "min": 0.9, "max": 0.9 }, "size": { "mode": "random", "min": 1.5, "max": 1.5 } }, "color": { "gradient": { "mode": "blend", "colorStops": [ { "position": 0, "color": [ 1, 0.93, 0.62 ] }, { "position": 1, "color": [ 1, 0.62, 0.18 ] } ], "alphaStops": [ { "position": 0, "alpha": 0 }, { "position": 0.2, "alpha": 0.6 }, { "position": 1, "alpha": 0 } ] } }, "billboard": { "sizeValue": { "mode": "curve", "curve": [ { "x": 0, "y": 0.95 }, { "x": 1, "y": 1.45 } ] } } }, { "id": "motes", "name": "Rising motes", "loop": false, "duration": 0.9, "maxParticles": 24, "render": { "blend": "additive", "depthTest": false, "depthInk": false }, "forces": { "gravity": 0, "gravityValue": { "mode": "constant", "value": 0 }, "drag": 0, "dragValue": { "mode": "constant", "value": 0 } }, "modules": { "color": true, "size": true, "velocity": true, "rotation": false }, "spawn": { "rate": 0, "bursts": [ { "time": 0, "count": 16, "cycles": 1, "interval": 0, "probability": 1 } ], "shape": "circle", "radius": 0.62, "radiusThickness": 0, "rotation": [ 90, 0, 0 ], "sphericalDirectionAmount": 0 }, "initializeParticle": { "lifetime": { "mode": "random", "min": 0.6, "max": 0.9 }, "size": { "mode": "random", "min": 0.05, "max": 0.08 }, "velocity": { "mode": "shapeDirection", "speed": { "mode": "random", "min": 0.25, "max": 0.5 } } }, "color": { "gradient":Source:https://dev.to/sam_novak_574b07811e18495/a-pixijs-level-up-halo-that-stays-behind-the-portrait-when-the-hud-scales-1pcm