How the game is drawn
phantom.aj.view controls where the room is viewed from. phantom.aj.render controls how the picture is made: how finely Flash rasterises it, whether the animals are smoothed, and what passes run over it afterwards.
None of it is sent to Animal Jam. It only changes this screen, other players see the game as normal, and everything is put back when your plugin stops, even if its own cleanup didn't run.
In short: the three changes that sharpen the game aren't shaders, and filters need care.
Why it looks soft
All of this comes from the decompiled client, deploy 1829.
The quality is fixed at medium. MainFrame.getInstance sets stage.quality to medium for the whole client. At low and medium Flash doesn't smooth bitmaps at all, whatever the bitmap asks for. high only smooths bitmaps that are standing still, and best smooths moving ones. Animals are always moving, so best is the only setting where smoothing does anything.
The client also keeps resetting it. MainFrame.handleResize writes it on every window resize, and picks low when stage.contentsScaleFactor is above one, so high-DPI screens get the worst quality. GamePlay.init writes medium as the world starts, and MinigameManager writes it either side of a minigame. A plain set of stage.quality only lasts until the next resize, so phantom.aj.render holds it instead: the loader checks every twelfth frame and puts it back.
The animals are bitmaps, not vectors. Each is a com.sbi.graphics.LayerBitmap, painted pixel by pixel from the image server's run-length data. The client never sets Bitmap.smoothing, so every animal is point sampled at every scale except exactly one, and LayerAnim.init sets pixelSnapping to always on each, snapping it to whole device pixels however it's scaled.
There's no higher resolution to ask for. ImageServerURL fetches imageArrays/<id> with no scale, dpi or quality parameter. The only gain is smooth sampling instead of nearest neighbour, but that's a big difference.
The room's art is cached, then scaled. The client sets cacheAsBitmap on the room's art layers (SceneLoader), on den items (DenItem, DenItemHelper) and on the name bar over every animal (NameBar). All of these are inside layerManager.bkg, which RoomManagerWorld.updateRoomZoom scales, and MainFrame.handleResize calls it with values as low as 70 for a large window. A cached object that's scaled is stretched from its cache rather than redrawn, which is why the room and the name tags go soft, especially on a big window.
The stage is 900 by 550 with scaleMode = "showAll", which Flash magnifies to fill the window. For real vector art that's free, because Flash rasterises vectors at the final size, so a bigger window does give more detail. Only the cached containers and the bitmaps miss out. Rendering at twice the size and downsampling is already what the game does; the problem is the caching and the smoothing, not the resolution.
The three changes that help
In this order, because each depends on the one before:
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
await phantom.aj.render.keepQuality('best');
await phantom.aj.render.smoothAnimals();
await phantom.aj.render.uncacheRoom();
return () => void phantom.aj.render.clear();
},
});keepQuality costs processor time every frame and can drop frames on a slow machine. medium with smoothed animals isn't possible, because medium ignores smoothing, so on a slow machine the choice is best at fewer frames or medium as before.
uncacheRoom only touches the room. The Journey Book's page flip and other parts of the interface rely on their caching and would break without it, so it starts at layerManager.bkg rather than the stage.
Each of these is a hold, not a one-off set. The loader walks the display tree again every few frames, so animals that enter the room later are smoothed too, and the original value on each object is remembered and restored when the hold ends. Holds come back by themselves after the game's page reloads the client (on a server switch, for example), because they name a place rather than an object the old loader held.
Filters and their cost
phantom.aj.render.grade, sharpen and bloom put Flash Player's built-in filters on the room, or on the whole game with where: 'game'. They're native code and much faster than anything you could write yourself, but they still cost something.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const off = [
await phantom.aj.render.grade({ saturation: 1.15, contrast: 0.08 }),
await phantom.aj.render.bloom({ radius: 8, strength: 1, quality: 1 }),
];
return () => {
for (const take of off) void take();
};
},
});A filter rasterises what it's on. Flash draws a filtered display object into a bitmap, then filters the bitmap. The room layer is scaled up to fill the window, so a filter on the room means the room is rasterised at its own size and then magnified. A sharpen on the room can leave the picture softer than before, especially on a large window. On game, the interface text suffers instead.
So keepQuality, smoothAnimals and uncacheRoom sharpen the picture without adding a pass. A filter adds a pass and loses some sharpness. Use filters for a look, not for sharpness.
Rough costs, on a 900 by 550 stage with a 41.7ms budget at the client's 24 frames a second:
| Pass | What it does per pixel | Worth it? |
|---|---|---|
grade | one 4 by 5 matrix multiply | the cheapest thing here; a look for almost nothing |
bloom quality 1 | one box pass each way, plus a composite | affordable over a room at radius 8 |
bloom quality 3 | three box passes each way | about six times quality 1; radius 16 at quality 3 will cost frames |
sharpen | nine samples and a weighted sum | dearest of the three, and the one that rings on Animal Jam's thick outlines |
shade | your kernel, once per pixel, on the processor | only for a kernel you already have |
gpu.shade | a second drawing of the game, 4 bytes a pixel uploaded, then your shader on the card | only for a pass no filter can do: curvature, aberration, a real bloom |
bloom is a GlowFilter: a blur of the object's shape, drawn underneath it. It isn't the bloom a modern renderer does, which blurs only the bright pixels. Flash can't read the picture back and blur part of it without drawing the whole frame into a bitmap first, which means rasterising everything a second time. The cheap version looks right on art with strong shapes, which Animal Jam has.
Shaders on the processor: Pixel Bender
phantom.aj.render.shade runs a Pixel Bender kernel. The bytecode is sent as base64 and the loader decodes it into the ByteArray that flash.display.Shader takes, so a kernel goes over in one request.
Two big caveats:
Pixel Bender runs on the processor. ShaderFilter is software. A kernel does real arithmetic for every pixel of the object it's on, every frame, and the built-in filters do the same jobs far faster. A kernel with a few operations per pixel is affordable. One that samples neighbours in a loop isn't.
There's no compiler. .pbj files came from Adobe's Pixel Bender Toolkit, which is no longer available, and Phantom ships nothing that can make one. shade is for a kernel somebody already has. A plugin ships only dist/plugin.js and dist/plugin.css, so the bytecode has to be base64 in the JavaScript.
To write your own shader, use the graphics card path below. Phantom can assemble its language.
Shaders on the graphics card: AGAL
phantom.aj.render.gpu.shade runs your own shader on the graphics card. It's the only way to reach one in Flash Player: Pixel Bender is software, while flash.display3D is hardware and runs AGAL (Adobe Graphics Assembly Language).
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const pass = await phantom.aj.render.gpu.shade({
fragment: `tex ft0, v0, fs0 <2d, clamp, linear, nomip>
mov oc, ft0`,
});
pass.onState((state) => {
if (state.state === 'drawing')
ctx.log(`${state.captureMs}ms of a ${state.budgetMs}ms frame`);
else ctx.log(state.says ?? state.error ?? state.state);
});
return () => void pass();
},
});You only write the fragment program. Phantom assembles the AGAL (packages/sdk/src/agal, from Adobe's published bytecode format), uploads it with its own fullscreen vertex program, and gives your shader:
| What | Where |
|---|---|
| the captured frame | sampler fs0, exactly the window's size, one texel per pixel |
| the screen position, 0 to 1 | varying v0 |
| your own numbers | fc0 upward, four to a register, from constants |
| the frame's own numbers | the register after those: [1 / width, 1 / height, seconds, frames] |
| the pixel you write | oc |
The assembler covers the published opcode table, both program types, every register (va, vc, vt, op, fc, ft, fs, oc, v), write masks, source swizzles and sampler options. It also checks things the bytecode can't: a fragment program reading a vertex attribute, ft8 being past the baseline profile's eight temporaries, an instruction producing three components without a masked destination. Mistakes come back as a rejected promise naming the line, not an error number from the card.
What a frame costs
Each frame of a pass does four things, and the shader is the cheap part:
- Capture. Flash won't hand what it has drawn to the card, so the game is drawn a second time into a
BitmapDatawithBitmapData.draw. That's a full software rasterisation on top of the normal one. - Upload.
Texture.uploadFromBitmapDatasends four bytes a pixel over the bus every frame: about 2MB at 900 by 550, nearly 6MB at 1600 by 900. - Shader. Close to free. A fullscreen pass of a few dozen instructions is nothing to a modern card.
- Hide. A
Stage3Dlayer composites behind the display list, so the game has to be hidden for the pass to show. The loader shows it for the capture and hides it again in the same frame handler, before the player composites anything, and shows it again as soon as the pass stops.
So this isn't worth it for a colour grade. ColorMatrixFilter does that natively for the cost of the matrix, with no capture or upload. It's worth it for what the processor can't do at 24 frames a second: reading the frame at computed coordinates, several times per pixel.
The numbers are measured. The loader times each step with getTimer, smooths them over about sixteen frames and keeps the worst single frame unsmoothed, and onState reports them: captureMs, uploadMs, drawMs and worstMs, against budgetMs (1000 divided by the stage's frame rate, about 41.7ms at the client's 24). Add the three up and compare. By default you get a report every 120 frames while the pass is drawing, because the state rarely changes but the timings move every frame.
Measure it
The capture cost depends on how much the game is drawing and how big the window is, and it's the number that decides whether a pass is worth it. Show it in your plugin (the High Quality plugin's panel does) so people can decide for themselves.
Without a graphics card
This case matters because it's common. WSL, for example, has no GPU process, so the desktop app turns hardware acceleration off there.
A pass that isn't drawing draws nothing and hides nothing, so the game looks exactly as Animal Jam drew it, and the state says why.
state | What it means |
|---|---|
waiting | No context yet. error has the player's refusal and it is still asking. |
software | A context arrived and it is the software renderer, which was handed straight back. |
failed | The programs would not upload. It will not try again. |
drawing | Running on the card. This is the only one in which any of the shader is on screen. |
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
let native: (() => Promise<void>) | null = null;
const pass = await phantom.aj.render.gpu.shade({ fragment: 'mov oc, v0' });
pass.onState(async (state) => {
if (state.state === 'drawing' && native) {
await native();
native = null;
} else if (state.state !== 'drawing' && !native) {
native = await phantom.aj.render.bloom({ radius: 8, quality: 1 });
}
});
return () => {
void pass();
void native?.();
};
},
});A software context is refused on purpose. A software Stage3D pass does the shader's arithmetic on the processor and still pays for the capture and upload, so it's always worse than the native filter for the same job. Pass allowSoftware: true to measure it anyway.
Two common refusals
Neither is about the graphics card, and both explain themselves in says.
Error 3688: the game was in the background. Flash won't give a Context3D to a movie it isn't drawing, and Phantom sets invisible on every game pane that isn't the front tab. A pass requested from the Scripts tool, or while another tab is open, is refused for that reason alone. It's temporary. The loader asks again every 48 frames by default, so the pass starts drawing as soon as someone looks at the game. Don't treat this refusal as final, or switching tabs for a second would break your plugin.
Error 3702: the movie is embedded in its own window. Stage3D needs wmode="direct". Phantom embeds the game as window by default, which is the fastest way to show a 2D game and is what the loading screen relies on. You can change it under Settings, then Games. A probe can't report hardware until it's direct.
Don't check wmodeGPU for this
Stage.wmodeGPU says whether Flash composites the display list on a card, which only happens with wmode="gpu". Stage3D runs on hardware under wmode="direct" with wmodeGPU false, so the flag says false on a machine with a perfectly good card. stage3Ds being 4 tells you nothing either: Flash always offers four layers.
The only reliable check is to ask for a Context3D and read driverInfo, which is what phantom.aj.render.gpu.report({ probe: true }) does, disposing of it afterwards. Without probe, driverInfo is always null, because the client never creates a context of its own.
import { definePlugin } from '@phantomclient/plugin';
export default definePlugin({
async setup(ctx) {
const phantom = ctx.phantom();
const card = await phantom.aj.render.gpu.report({ probe: true });
if (card.hardware) ctx.log(`a card is available: ${card.driverInfo}`);
else ctx.log(card.says ?? card.error ?? 'nothing asked');
},
});What's worth running on the card
Only passes no filter can do, since nothing else pays for the capture. The High Quality plugin ships three, all off by default:
| Shader | Samples a pixel | Why no filter can do it |
|---|---|---|
| CRT, curved with scanlines | 1 | The curve reads the frame at a coordinate worked out per pixel. A convolution reads fixed neighbours and a colour matrix reads one pixel; neither can move where it reads from. |
| Chromatic aberration | 3 | The three colour channels come from three different places. A colour matrix can swap channels around; nothing in Flash can fetch one from elsewhere, at any price. |
| Bloom, the real one | 13 | The light spreading out of the bright pixels of the picture. GlowFilter blurs the shape of what it is on and draws that underneath: a halo, not light. The real thing needs the frame read back and only the bright parts blurred. |
Bloom shows it best. Thirteen texture samples per pixel at 900 by 550 is about six and a half million samples a frame, which a graphics card handles easily and no processor can do at 24 frames a second. It's also the only one with a fallback: the plugin falls back to render.bloom and says in its panel that it's a different effect with the same name.
Animal Jam is 2D vector art, so there's no scene geometry to relight and no depth to shade. Curvature, aberration, bloom, grading and distortion are all that's on offer, hardware or not.
Limits
- One pass at a time. Each pass needs the whole window and needs the game hidden. Put several effects in one fragment program instead.
- The baseline profile, which is what
requestContext3Dgives by default: 200 instructions in a fragment program, 8 temporaries, 28 constants, 8 samplers, and a back buffer of at most 2048 pixels a side. The assembler names the limit when you go over any of the first four. A game pane wider than 2048 leaves the pass waiting rather than covering part of the window. - Constants are written once, when the programs are uploaded. To change one, request the pass again. The High Quality plugin does this when its strength slider moves.
- A lost context is recovered. The card drops its context when the computer sleeps or the driver restarts. The loader hears the player create a new one, rebuilds everything, and shows the game in the meantime.
Undoing it
Every filter resolves with the function that removes it. phantom.aj.render.clear() removes everything the area added: every pass, the quality hold, the smoothing and the room's caching, all back to how the client had them. It does nothing if nothing was added, so it's always safe to call in a teardown.
Phantom does the same if your plugin's frame goes away without its cleanup running, so a crashed plugin doesn't leave someone's game graded.