|
| 1 | +--- |
| 2 | +name: melonjs-display-and-scaling |
| 3 | +description: "Use this skill when a melonJS game looks blurry, soft, shimmering or cropped, or when choosing how the canvas fills its page. Covers the design resolution, scale and scaleMethod, scaleTarget, scaleRounding for pixel-perfect integer scaling, antiAlias, textureFilter, subPixel, devicePixelRatio, and which setting does which job. Triggers on: blurry, soft, fuzzy, shimmering, pixel art, pixel perfect, crisp, nearest, linear, scale, scaleMethod, scaleTarget, scaleRounding, integer scaling, antiAlias, textureFilter, subPixel, devicePixelRatio, retina, hidpi, design resolution, canvas size, canvas cropped, letterbox, image-rendering, upscale, resolution." |
| 4 | +license: MIT |
| 5 | +--- |
| 6 | + |
| 7 | +# Display and scaling |
| 8 | + |
| 9 | +Seven settings decide how a game looks on screen, and they are easy to get |
| 10 | +individually right and collectively wrong. This is what each one actually |
| 11 | +does, and which one to reach for. |
| 12 | + |
| 13 | +## The four jobs |
| 14 | + |
| 15 | +A pixel travels from your source art to a physical pixel through four |
| 16 | +separate decisions. Knowing which setting owns which is most of the work. |
| 17 | + |
| 18 | +| job | setting | what it decides | |
| 19 | +|---|---|---| |
| 20 | +| how big the frame is drawn | `width`/`height` on `Application` | the design resolution everything is authored against | |
| 21 | +| how it uses the space | `scale`, `scaleMethod`, `scaleTarget` | fit, fill, stretch, and what it measures to decide | |
| 22 | +| may the factor be fractional | `scaleRounding` | whole device pixels per source pixel, or any factor | |
| 23 | +| how a texture is sampled | `antiAlias`, `textureFilter` | nearest or linear, and the CSS upscale filter | |
| 24 | + |
| 25 | +`subPixel` is a fifth, orthogonal to all of them: it decides whether a |
| 26 | +renderable may sit on a fractional position WITHIN the frame. |
| 27 | + |
| 28 | +## Design resolution |
| 29 | + |
| 30 | +```js |
| 31 | +const app = new Application(480, 270, { scale: "auto" }); |
| 32 | +``` |
| 33 | + |
| 34 | +The game is drawn at 480x270 whatever the window is, and the canvas is scaled |
| 35 | +to the container. Everything in world coordinates is in those units. This is |
| 36 | +the usual arrangement: author once, scale to whatever you are given. |
| 37 | + |
| 38 | +## `scale` and `scaleMethod` |
| 39 | + |
| 40 | +`scale: "auto"` turns on automatic scaling; a number is a fixed multiplier and |
| 41 | +`scaleMethod` is then ignored. |
| 42 | + |
| 43 | +`scaleMethod` decides how the available space is used: |
| 44 | + |
| 45 | +- **`"fit"`** keeps the design resolution and the aspect ratio, scaling to the |
| 46 | + largest factor that fits. Leftover space becomes margin. This is the one to |
| 47 | + use when the game is authored for a fixed frame. |
| 48 | +- **`"fill-min"` / `"fill-max"`** keep the aspect ratio but RESIZE the |
| 49 | + renderer, so the design resolution changes with the window and the game sees |
| 50 | + more or less of the world. |
| 51 | +- **`"flex"`, `"flex-width"`, `"flex-height"`** resize the renderer on one or |
| 52 | + both axes. |
| 53 | +- **`"stretch"`** scales each axis independently, so the aspect ratio is not |
| 54 | + preserved and circles become ovals. |
| 55 | +- **`"manual"`** does nothing automatic. |
| 56 | + |
| 57 | +The distinction that matters: **`fit` scales a fixed frame; everything else |
| 58 | +changes how much of the world is visible.** A game whose layout assumes a |
| 59 | +known frame wants `fit`. |
| 60 | + |
| 61 | +## `scaleTarget`: what gets measured |
| 62 | + |
| 63 | +Automatic scaling measures **the parent of the element the canvas is mounted |
| 64 | +in**, not the element itself. If that grandparent is not the box you meant, |
| 65 | +the canvas is sized wrongly, and the failure is quiet. |
| 66 | + |
| 67 | +The case that bites: a canvas container that is `position: fixed` or |
| 68 | +`absolute` contributes no height to its parent, so the parent measures only |
| 69 | +whatever else is in it. If that parent is `<body>`, the engine falls back to |
| 70 | +the window size and the canvas ends up larger than the box it lives in, |
| 71 | +cropped by whatever sits above it. |
| 72 | + |
| 73 | +```js |
| 74 | +new Application(1024, 576, { |
| 75 | + parent: "screen", |
| 76 | + scale: "auto", |
| 77 | + // measure THIS, rather than guessing from the DOM shape |
| 78 | + scaleTarget: document.getElementById("stage"), |
| 79 | +}); |
| 80 | +``` |
| 81 | + |
| 82 | +Either name the element with `scaleTarget`, or give the canvas container a |
| 83 | +parent whose box is the area you want filled. If the canvas is cropped or |
| 84 | +overflows on resize, this is the first thing to check. |
| 85 | + |
| 86 | +## `scaleRounding`: whole device pixels |
| 87 | + |
| 88 | +```js |
| 89 | +new Application(480, 270, { |
| 90 | + scale: "auto", |
| 91 | + scaleMethod: "fit", |
| 92 | + scaleRounding: "integer", // default "fractional" |
| 93 | +}); |
| 94 | +``` |
| 95 | + |
| 96 | +`"fractional"` fills the container exactly, at whatever factor that takes. A |
| 97 | +480 wide design in a 1280 wide container is scaled by 2.667, so some source |
| 98 | +pixels cover three screen pixels and some cover two. Pixel art shows this as |
| 99 | +uneven glyph stems and shimmering edges, and browsers resolve the unevenness |
| 100 | +differently, so the same scene can look crisp in one and soft in another. |
| 101 | + |
| 102 | +`"integer"` rounds the factor DOWN so every source pixel covers the same whole |
| 103 | +number of device pixels, and letterboxes with the space left over. That trade, |
| 104 | +crisp pixels for black bars, is the whole decision. |
| 105 | + |
| 106 | +### What it costs |
| 107 | + |
| 108 | +More than "some letterboxing", and the size of it is decided by |
| 109 | +`devicePixelRatio` as much as by the window. A higher ratio has more device |
| 110 | +pixels to round among, so it lands closer to the factor it wanted. For a |
| 111 | +480x270 design, which is exactly a quarter of 1920x1080: |
| 112 | + |
| 113 | +| container | ratio | fit | integer | presented | area lost | |
| 114 | +|---|---|---|---|---|---| |
| 115 | +| 1920x1080 | 1 or 2 | 4.000 | 4 / 8 | 1920x1080 | none | |
| 116 | +| 1440x810 | 1 | 3.000 | 3 | 1440x810 | none | |
| 117 | +| 960x540 | 1 | 2.000 | 2 | 960x540 | none | |
| 118 | +| 1280x720 | 2 | 2.667 | 5 | 1200x675 | 12% | |
| 119 | +| 2560x1440 | 2 | 5.333 | 10 | 2400x1350 | 12% | |
| 120 | +| 1280x720 | 1 | 2.667 | 2 | 960x540 | **44%** | |
| 121 | +| 1366x768 | 1 | 2.844 | 2 | 960x540 | **51%** | |
| 122 | + |
| 123 | +So the decision is mostly made when the design resolution is chosen, not when |
| 124 | +this setting is. One that divides the sizes you expect costs nothing; one that |
| 125 | +does not can give up half the screen on a 1x display, and 1366x768 laptops are |
| 126 | +not rare. A game that must fill the window on any machine should stay |
| 127 | +fractional and accept the uneven pixels. |
| 128 | + |
| 129 | +Two things to know: |
| 130 | + |
| 131 | +- It applies to **`scaleMethod: "fit"` only.** Every other method resizes the |
| 132 | + renderer to the container, so the design resolution moves and there is no |
| 133 | + fixed factor to round. |
| 134 | +- The rounding happens in **device** pixels. `devicePixelRatio` multiplies the |
| 135 | + CSS size on the way to real pixels, so rounding the CSS factor would let the |
| 136 | + fraction straight back in: a factor of 2 at a ratio of 1.25 is 2.5 device |
| 137 | + pixels per source pixel. |
| 138 | + |
| 139 | +## `antiAlias` does three things |
| 140 | + |
| 141 | +This is the setting most likely to surprise, because it is not one switch: |
| 142 | + |
| 143 | +1. the texture filter, but only while `textureFilter` is `"auto"` (the |
| 144 | + default), which TRACKS it: linear when on, nearest when off. Setting |
| 145 | + `textureFilter` explicitly decouples the two |
| 146 | +2. `imageSmoothingEnabled` on the 2D context |
| 147 | +3. the canvas CSS `image-rendering`: `pixelated` when off, `auto` when on |
| 148 | + |
| 149 | +So `antiAlias: false` gives hard-edged textures AND a hard-edged upscale of |
| 150 | +the whole frame. That is usually what a pixel-art game wants, and usually NOT |
| 151 | +what a smooth 3D scene wants, because it also disables MSAA. |
| 152 | + |
| 153 | +`textureFilter: "nearest" | "linear"` separates the texture half, so a scene |
| 154 | +can have smooth model edges and nearest-sampled sprites. The presentation half |
| 155 | +is not separable: there is no way to get MSAA silhouettes and a hard-edged |
| 156 | +upscale at the same time. |
| 157 | + |
| 158 | +## `subPixel` is about position, not scale |
| 159 | + |
| 160 | +`subPixel: false` (the default) truncates a renderable's translation to whole |
| 161 | +pixels inside the frame. It is `|0`, so it truncates **toward zero** rather |
| 162 | +than rounding, which is worth knowing when something travels a short distance: |
| 163 | +an offset that should swing between -3 and +3 lands on -2 and +2, and parks at |
| 164 | +the extremes for longer than it should. |
| 165 | + |
| 166 | +It does nothing about how the frame reaches the screen. A game that carefully |
| 167 | +snaps positions and is then scaled by 2.667 has thrown that work away at the |
| 168 | +last step, which is the contradiction `scaleRounding: "integer"` exists to |
| 169 | +resolve. |
| 170 | + |
| 171 | +## Soft text next to crisp sprites |
| 172 | + |
| 173 | +If everything is crisp EXCEPT the text, the scale factor is not the cause: a |
| 174 | +fractional factor softens the whole frame equally. The text is different |
| 175 | +because of how it is made. |
| 176 | + |
| 177 | +`Text` draws through the browser's `fillText`, which anti-aliases glyph edges |
| 178 | +into partial-alpha pixels. Measured on one label at 16px: **171 distinct |
| 179 | +partial alpha levels**. Nothing in the canvas API turns that off. `antiAlias` |
| 180 | +and the `imageSmoothingEnabled` that `CanvasRenderTarget` sets govern IMAGE |
| 181 | +drawing, not glyph rasterisation. |
| 182 | + |
| 183 | +So the label arrives already soft, and the upscale then magnifies each grey |
| 184 | +edge pixel into a grey block, sitting beside sprites that were hard-edged to |
| 185 | +begin with and stay hard-edged under nearest sampling. |
| 186 | + |
| 187 | +It also explains the same scene looking crisp in one browser and soft in |
| 188 | +another at identical size and settings: engines use different font |
| 189 | +rasterisers, with different hinting and anti-aliasing, so they bake DIFFERENT |
| 190 | +textures from the same font at the same size. |
| 191 | + |
| 192 | +**Use `BitmapText` for a game that upscales.** A pixel-font atlas is |
| 193 | +hard-edged source art like every other texture, so it survives magnification |
| 194 | +the way the sprites do. Raising the design resolution only shrinks the |
| 195 | +magnification of anti-aliasing that is still there, so it softens the symptom |
| 196 | +rather than removing it, and `scaleRounding` does nothing for this at all. |
| 197 | + |
| 198 | +Keep `Text` where the frame is not upscaled much, or where the label is meant |
| 199 | +to look smooth. |
| 200 | + |
| 201 | +### Baking an atlas from a face you already use |
| 202 | + |
| 203 | +Swapping in a bitmap face means generating one, and three things decide |
| 204 | +whether it comes out looking like the original: |
| 205 | + |
| 206 | +- **Threshold the coverage.** Hard edges are the whole point, so every pixel |
| 207 | + has to end up fully on or fully off. Hardening only the ALPHA is not enough: |
| 208 | + an outlined glyph keeps a soft stroke-to-fill blend INSIDE the silhouette, |
| 209 | + which still reads as blur once magnified. Pick the colours from a mask. |
| 210 | +- **Bake each size at that size, and draw it at a ratio of 1.** `size` on |
| 211 | + `BitmapText` is a ratio of the authored size, and a bitmap face has the sizes |
| 212 | + it was baked at. Doubling a smaller atlas halves the detail; a ratio below 1 |
| 213 | + drops whole stems; a fractional ratio resamples hard pixels unevenly. One |
| 214 | + atlas per step of the type scale. |
| 215 | +- **Grow the outline outward, but carve it INWARD around a counter.** This is |
| 216 | + the one that costs the most time. A stroke drawn by the canvas is centred on |
| 217 | + the contour, so around an enclosed hole it eats into the letter and the hole |
| 218 | + keeps its size. Dilating a mask outward instead grows black INTO the hole |
| 219 | + from every side, which closes anything under about 3px: the letter loses its |
| 220 | + counter and reads as a blob. Take the rim from the fill side and the hole |
| 221 | + survives at every size. Neither leaving holes unrimmed nor closing the small |
| 222 | + ones is a substitute — both were tried and both look wrong. |
| 223 | + |
| 224 | +Two traps in the generator itself: |
| 225 | + |
| 226 | +- A face whose glyphs overshoot the `textBaseline` origin is clipped by a tight |
| 227 | + cell, and the symptom is an outline that goes missing along one edge of some |
| 228 | + glyphs. Pad the cell by a fraction of the size, and assert that no glyph's |
| 229 | + ink reaches its border. |
| 230 | +- The threshold decides the TOPOLOGY, not just the weight. A notch or a narrow |
| 231 | + gap in a letterform closes over at a full threshold and becomes an isolated |
| 232 | + hole, which then bakes as a black blob sitting on the glyph. Thinner, the |
| 233 | + same feature stays joined to the background and reads as the face intends. |
| 234 | + So a small size may want a thinner fill than a large one, and the way to see |
| 235 | + it is to magnify one glyph, not a whole line. |
| 236 | + |
| 237 | +## A pixel-art setup |
| 238 | + |
| 239 | +```js |
| 240 | +const app = new Application(480, 270, { |
| 241 | + scale: "auto", |
| 242 | + scaleMethod: "fit", |
| 243 | + scaleRounding: "integer", // whole device pixels, letterbox the rest |
| 244 | + antiAlias: false, // nearest textures, hard-edged upscale |
| 245 | + // subPixel: false is already the default |
| 246 | +}); |
| 247 | +``` |
| 248 | + |
| 249 | +## A smooth 3D setup |
| 250 | + |
| 251 | +```js |
| 252 | +const app = new Application(1024, 576, { |
| 253 | + scale: "auto", |
| 254 | + scaleMethod: "fit", |
| 255 | + antiAlias: true, // MSAA and a filtered upscale |
| 256 | + textureFilter: "nearest", // but keep blocky textures if the art is blocky |
| 257 | +}); |
| 258 | +``` |
| 259 | + |
| 260 | +## Symptoms |
| 261 | + |
| 262 | +| symptom | cause | |
| 263 | +|---|---| |
| 264 | +| pixel art is blurry or shimmers | the frame is upscaled by a fractional factor; set `scaleRounding: "integer"` | |
| 265 | +| crisp in one browser, soft in another | the same fractional factor, which engines distribute differently under `pixelated` | |
| 266 | +| the canvas is cropped or overflows its box | automatic scaling measured the wrong element; name it with `scaleTarget` | |
| 267 | +| the canvas grows on every resize | the measured ancestor is auto-sized around the canvas itself | |
| 268 | +| things shimmer as they move | fractional positions; check `subPixel`, and that the scale factor is whole | |
| 269 | +| 3D model edges are jagged | `antiAlias: false` also disables MSAA; turn it on and use `textureFilter` for textures | |
| 270 | +| textures are smoothed when they should be blocky | `antiAlias: true` makes the default texture filter linear; set `textureFilter: "nearest"` | |
| 271 | +| `scaleRounding: "integer"` changes nothing | it applies to `scaleMethod: "fit"` only; the others resize the renderer | |
| 272 | +| text is soft but sprites are crisp | the glyphs were anti-aliased when `Text` baked them; use `BitmapText` (see below) | |
| 273 | +| the game shows more world on a bigger window | that is `fill-*` or `flex-*` doing their job; use `"fit"` to keep a fixed frame | |
0 commit comments